Type-safe delegate

I noticed that Object#delegate wasn’t type-safe, it just takes splats for arguments rather than copying the argument types from the delegated-to method. And it didn’t handle blocks. So, I worked out a macro to make a type-safe one, that handles blocks, but I can’t get the syntax to be exactly like the Object#delegate. Notice that I am calling A.delegate(method-name, to: object) below, with the class-name at the start. Can you think of a way for me to make the syntax just like the existing Object#delegate ?

Note: Don’t look at this one, there is a much shorter version below!

class Object
  macro delegate2(name, to)
    {% methods = @type.methods %}
    {% got_name = false %}
    {% for m in methods %}
      {% if m.name.id == name.id %}
        {% got_name = true %}
        {% has_yield = false %}
        {% if m.body.class_name == "Expressions" %}
          {% for node in m.body.expressions %}
            {% if node.class_name == "Yield" %}
              {% has_yield = true %}
            {% end %}
          {% end %}
        {% end %}
        {% if m.body.class_name == "Yield" %}
          {% has_yield = true %}
        {% end %}
        def {{name}}({{m.args.join(",").id}}) {{ m.return_type.stringify != "" ? ": #{m.return_type}".id : "".id }}
          {{to}}.{{name}}({{m.args.map{ |arg| arg.internal_name }.join(", ").id}}) {{has_yield ? "do |*args| yield *args end".id : "".id}}
        end
      {% end %}
    {% end %}
    {% if !got_name %}
      {% raise "delegate: Method \"#{name}\" wasn't found in \"#{@type.name}\"." %}
    {% end %}
    {% debug %}
  end
end

class A
  def foo(a : Int32) : String
    yield
  end
end

class B
  @a = A.new
  A.delegate2(foo, to: @a)
end

b = B.new
p b.foo(1) { "Hello" }

To do: Macros::Yield doesn’t give enough data to make the block type-safe, unless a &block argument is defined. The code above doesn’t currently get the macro argument information from the block argument, it just always uses splat.

Related: https://github.com/crystal-lang/crystal/issues/9927.

@Blacksmoke16 My macro version now handles blocks. I updated the code above.

1 Like

I cleaned up the macro a bit for you :blush:

  macro delegate2(name, to)
    {% if m = @type.methods.find &.name.id.==(name.id) %}
      def {{name}}({{m.args.splat}}) {% if !m.return_type.is_a?(Nop) %}: {{m.return_type}} {% end %}
        {{to}}.{{name}}({{m.args.map { |arg| arg.internal_name }.splat}}) {% if m.accepts_block? %}{ |*args| yield *args }{% end %}
      end
    {% else %}
      {% raise "delegate: Method \"#{name}\" wasn't found in \"#{@type.name}\"." %}
    {% end %}
    {% debug %}
  end

@Blacksmoke16, Thank you. As is clear, I am still learning this.

Here we go, a drop shorter than your cleanup!

class Object
  macro delegate2(name, to)
   {% if m = @type.methods.find &.name.id.==(name.id) %}
     def {{name}}({{m.args.splat}}) {% if !m.return_type.is_a?(Nop) %}: {{m.return_type}} {% end %}
       {{to}}.{{name}}({{m.args.map(&.internal_name).splat}}) {% if m.accepts_block? %}{ |*args| yield *args }{% end %}
     end
   {% else %}
     {% raise "delegate: Method \"#{name}\" wasn't found in \"#{@type.name}\"." %}
   {% end %}
   {% debug %}
  end
end

class A
  def foo(a : Int32) : String
    yield
  end
end

class B
  @a = A.new
  A.delegate2(foo, to: @a)
end

b = B.new
p b.foo(1) { "Hello" }

New version: handles type-restricted block arguments, like &block : Int32 -> String

class Object
  macro delegate2(name, to)
   {% if m = @type.methods.find &.name.id.==(name.id) %}
     {% if m.block_arg %}
       {% args = (m.args + ["&#{m.block_arg}".id]).splat %}
       {% yield_args = m.block_arg.restriction.inputs.map_with_index{|a,index| "arg#{index}".id}.splat %}
     {% else %}
       {% args = m.args.splat %}
       {% yield_args = "*args".id %}
     {% end %}
     def {{name}}({{args}}) {% if !m.return_type.is_a?(Nop) %}: {{m.return_type}} {% end %}
       {{to}}.{{name}}({{m.args.map(&.internal_name).splat}}) {% if m.accepts_block? %}{ |{{yield_args}}| yield({{yield_args}})}{% end %}
     end
   {% else %}
     {% raise "delegate: Method \"#{name}\" wasn't found in \"#{@type.name}\"." %}
   {% end %}
   {% debug %}
  end
end

class A
  def foo(a : Int32, b : Int32, &block : Int32, Int32 -> String) : String
    yield a, b
  end
end

class B
  @a = A.new
  A.delegate2 foo, to: @a
end

b = B.new
p b.foo(1, 2) { |i, j| "Hello" }
2 Likes