Capture block forwarding between two classes

Here is an example of code reduced to the simplest, but representative of what I want to achieve in my project, a “capture block forwarding” between 2 classes. Of course, as it is, it is not functional!

class Table(T)
  @column_registry = {} of String => Column(T)

  def initialize(@sources : Enumerable(T))
  end

  def to_s
    @sources.each do |source|
      @column_registry.each do |label, column|
        puts column.extracted_data(source)
      end
    end
  end

  def add_column(label : String, header : String, &extractor)
    @column_registry[label] = Column(T).new(header, extractor)
  end
end

class Column(T)
  def initialize(@header : String, @extractor : T -> _)
  end

  def extracted_data(source)
    @extractor.call(source)
  end
end

t = Table.new([[1, "abc", 3.14]])
t.add_column("A", "header")  { |n| n[1] }
puts t.to_s

The error I get is:

error in line 30
Error: wrong number of block parameters (given 1, expected 0)

I’m stuck on this :frowning_face:

I think you just need to type your captured block. E.g. &extractor : T ->. Otherwise Crystal assumes it doesn’t have any arguments and doesn’t return anything.

Ref: Capturing blocks - Crystal

With def add_column(label: String, header: String, &extractor: T -> ) the program runs, but returns nil (which is expected according to the documentation).

With def add_column(label : String, header : String, &extractor : T -> _), the type of extractor is correctly identified, but it is not “transmitted” to the instantiation of Column(T).

The error is :

error in line 16
Error: instantiating ‘Column(Array(Float64 | Int32 | String)).class#new(String, Proc(Array(Float64 | Int32 | String), (Float64 | Int32 | String))’

error in line 21
Error: can’t infer the type of instance variable ‘@extractor’ of Column(Array(Float64 | Int32 | String))

Could you add a type annotation like this

class Column(Array(Float64 | Int32 | String))
  @extractor : Type
end

replacing Type with the expected type of @extractor?

I got the program working by adding the correct type (Int32 | String | Float64) in both the add_column method and in the Column initialize method, but this is precisely what I want to avoid!

I think that the only alternative is to declare a module CellValue, make that module be included in all the types you expect to be used, and use that module as the return type of your procs.

Is a way to declare a union in an open way. If a new type comes to the game you don’t need to alter existing code.

You can then define methods like def render_cell(io : IO) that might come handy in the future.

Thanks for your answer.
Following your recommendations, I now get a new error at line:

t.add_column("A", "header")  { |n| n[1] }

The error is:

error in line 37
Error: expected block to return CellType, not (Float64 | Int32 | String)

Here is my code :

module CellType
end

class Table(T)
  include CellType
  @column_registry = {} of String => Column(T)

  def initialize(@sources : Enumerable(T))
  end

  def to_s
    @sources.each do |source|
      @column_registry.each do |label, column|
        puts column.extracted_data(source)
      end
    end
  end

  def add_column(label : String, header : String,
      &extractor : T -> CellType)
    @column_registry[label] = Column(T).new(header, extractor)
  end
end

class Column(T)
  include CellType
  def initialize(@header : String, @extractor : T -> CellType)
  end

  def extracted_data(source)
    @extractor.call(source)
  end
end


t = Table.new([[1, "abc", 3.14]])
t.add_column("A", "header") { |n| n[1] }
puts t.to_s

I must be missing something: how can the relationship between CellType and the actual type be performed?

module CellType is not a subtype of String | Int32 | Float64 so that’s not really possible. I would say go with an alias CellType = String | Int32 | Float64 which you can then cast to CellType in your block if necessary, then handle the different value types of @column_registry accordingly.

What @Devonte says.

Here is an adapted version of the code. It allows overriding render_cell as needed for each type.

module CellType
  def render_cell(io : IO)
    to_s(io)
  end
end

struct Float32
  include CellType
end

struct Int32
  include CellType
end

class String
  include CellType
end

class Table(T)
  include CellType
  @column_registry = {} of String => Column(T)

  def initialize(@sources : Enumerable(T))
  end

  def to_s(io : IO)
    @sources.each do |source|
      @column_registry.each do |label, column|
        column.extracted_data(source).render_cell(io)
      end
    end
  end

  # R will be forced to be convertible to CellType
  # but to allow blocks returning individual types
  # for this extractor argument we need to the cast
  # internally. All is typesafe still \o/
  def add_column(label : String, header : String,
                 &extractor : T -> R) forall R
    @column_registry[label] = Column(T).new(header,
      Proc(T, CellType).new { |row|
        extractor.call(row).as(CellType)
      }
    )
  end
end

class Column(T)
  include CellType

  def initialize(@header : String, @extractor : T -> CellType)
  end

  def extracted_data(source)
    @extractor.call(source)
  end
end

t = Table.new([[1, "abc", 3.14]])
t.add_column("A", "header") { |n| n[1] }
puts t.to_s

Wow, great!
I just added a macro to automatically include CellType in all standard types.
Thanks a lot, really.

NB. I just have to study a little bit the subtlety of the code of the add_column method :thinking: :slight_smile: :slight_smile: