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 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.
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).
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.
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