Skip to content

Tutorial: Automatic Record Methods

Eduardo Bart edited this page Jul 2, 2023 · 1 revision

Sometimes it's useful to define methods automatically via meta-programming, based on its first usage, this recipe will show how to do this in Nelua.

Note that this recipe requires some familiarity with Nelua meta-programming first.

People familiar with Lua know it's possible to do this via the __index metamethod and with Ruby know this is possible with the method_missing idiom.

In Nelua this is also possible to do similar at compile-time; that is, you can define new methods based on its name on its first usage and there won't be runtime costs, because this will be done at compile-time.

This is useful, for example, to implement auto swizzling methods on math vector types, a common idiom used to code GPU shaders (what swizzling methods are will be described shortly), but they can make code with lots of math vector algebra more readable and simple.

This recipe will ultimately present an implementation for swizzling with some meta-programming.

To begin simple, let's define our math vector types:

local vec2: type = @record{x: number, y: number}
local vec3: type = @record{x: number, y: number, z: number}
local vec4: type = @record{x: number, y: number, z: number, w: number}

local v: vec4 = {x=1, y=2, z=3, w=4}
print(v.x, v.y, v.z, v.w) -- outputs: 1.0 2.0 3.0 4.0

A swizzling method in this case would be a method that converts a vec type into other vec type, optionally changing the elements order; for example, let's implement one manually:

-- Swizzling method that returns a vec3 with elements `z`, `y` and `x`.
function vec4.zyx(self: vec4): vec3
  return vec3{x=self.z, y=self.y, z=self.x}
end

local v: vec4 = {x=1, y=2, z=3, w=4}
local rv3: vec3 = v:zyx() -- a vec3 with the first 3 elements of `v` in reverse order
print(rv3.x, rv3.y, rv3.z) -- outputs: 3.0 2.0 1.0

Now suppose you want to implement all swizzling methods combinations for all vec types manually; that would be about 4*4*4*4 + 4*4*4 + 4*4 = 336 different methods for vec4, 3*3*3*3 + 3*3*3 + 3*3 = 117 different methods for vec3, and 2*2*2*2 + 2*2*2 + 2*2 = 28 different methods for vec2, in total 481 methods!

That is a lot of methods to implement manually and still a lot of methods to implement even via simple code generation of all combinations, because most of them will probably never be used, and this would put additional needless work in our compiler, thus increasing compile time.

Enter automatic method generation!

The idea is simple; what if we have a way to implement vec4.zyx automatically on its first usage?

Well this is possible with some meta-programming; if we hook the first time the compiler tries to index .zyx method and we define the method right away before the compiler uses it:

##[[
local skip = false
setmetatable(vec4.value.metafields, {__index = function(metafields, name)
  if skip then return end -- avoid recursive calling `__index`
  if not name:match('^[xyzw]+$') then return end -- filter unwanted method names
  skip = true
  print('defining method '..name) -- print index attempt (for debug purposes)
  ]]
  function vec4:#|name|#() -- defined the method
    print(#['in method '..name]#)
  end
  ##[[
  skip = false
  return rawget(metafields, name)
end})
]]

local v: vec4 = {x=1, y=2, z=3, w=4}
v:xxx()
v:xxx()

The above example will produce the following output:

defining method 'xxx'
in method xxx
in method xxx

The defining method 'xxx' is a message shown only at compile-time, for debug purposes; note that it's only shown once, so we are really defining that method just once. And the in method xxx is a message shown twice, because we called the method xxx two times in our test.

Great!

With this basic covered, now we only need to come up with some macros to implement what we desire; here it's the full working example of automatically defining swizzling methods:

-- Macro that automatically implements swizzling methods for math vec types.
##[[
local function vec_swizzling_methods(vecT)
  static_assert(traits.is_symbol(vecT), 'expected a symbol!')
  vecT = vecT.value -- get the symbol holded type
  static_assert(traits.is_type(vecT), 'expected a symbol to a type!')
  local skip = false -- used to avoid infinite lookup recursion
  local function auto_swizzling_callback(metafields, name)
    if skip then return end -- avoid recursive calling `__index`
    if not name:match('^[xyzw]+$') then return end -- filter unwanted method names
    skip = true
    ]]
    function #[vecT]#.#|name|#(self: #[vecT]#): auto <inline>
      ## if #name == 4 then
        return vec4{x=self.#|name:sub(1,1)|#, y=self.#|name:sub(2,2)|#,
                    z=self.#|name:sub(3,3)|#, w=self.#|name:sub(4,4)|#};
      ## elseif #name == 3 then
        return vec3{x=self.#|name:sub(1,1)|#, y=self.#|name:sub(2,2)|#,
                    z=self.#|name:sub(3,3)|#};
      ## elseif #name == 2 then
        return vec2{x=self.#|name:sub(1,1)|#, y=self.#|name:sub(2,2)|#};
      ## end
    end
##[[
    skip = false
    return rawget(metafields, name)
  end
  -- hygienize, so we see only symbols available at the time this macro is called
  auto_swizzling_callback = hygienize(auto_swizzling_callback)
  -- non existent methods will call auto_swizzling_callback
  setmetatable(vecT.metafields, {__index = auto_swizzling_callback})
end
]]

local vec2: type = @record{x: number, y: number}
local vec3: type = @record{x: number, y: number, z: number}
local vec4: type = @record{x: number, y: number, z: number, w: number}

-- Implement swizzling methods for vec types.
## vec_swizzling_methods(vec4)
## vec_swizzling_methods(vec3)
## vec_swizzling_methods(vec2)

do -- Test swizzling
  local v = vec4{x=1, y=2, z=3, w=4}
  assert(v:xyx() == vec3{x=1, y=2, z=1})
  assert(v:yz() == vec2{x=2, y=3})
  assert(v:xw():yyyy() == vec4{x=4, y=4, z=4, w=4})

  local rv = v:wzyx()
  print('reverse v is')
  print(rv.x, rv.y, rv.z, rv.w) -- outputs: 4.0 3.0 2.0 1.0
end

Note that methods xyx, yz, xw, yyyy are not manually defined; they were defined using meta-programming, compile-time magic!

Swizzling for vec types like the above is widely used in shader languages like GLSL and HLSL, they are handy to make math intensive algorithms.

That is for this recipe; this can be used for other interesting stuff.

For example, it could be used to define methods that do different things based on their name, like in Ruby world some libraries automatically implement methods like find_by_ or find_all_by_ suffixed with a field name, to specialize different find functions based on the field name.