Crystal: Question: generic way to forward named arguments to initialize?

Created on 6 May 2016  路  9Comments  路  Source: crystal-lang/crystal

Hello,

With 0.16 is now possible alter the order in which arguments are given when initialize a new instance:

class Question
  def initialize(@question : String, @answer : String)
  end
end

q1 = Question.new(answer: "42", question: "Life")
# => #<Question:0x17ddf20 @question="Life", @answer="42">

Using traditional (positional) arguments also work when using class methods to forward them:

class Question
  def self.call(*args)
    new(*args)
  end
 end

q2 = Question.call("Life", "42")
# => #<Question:0x17ddf40 @question="Life", @answer="42">

However, it is not possible to forward those from a class method (or any other method) to the initialize method, as attempt to do so, fails on compilation (as expected);

# Compile error: no argument named 'question'
# Matches are:
# - Question.call(*args)
q3 = Question.call(question: "Life", answer: "42")

The real-life use case for this is to construct an special instance of a class and be able to perform other actions before returning not the instance created, but a result of executing it. The self.call method is actually included from a module that is used to normalize a common interface across these objects.

Perhaps that is possible, but I'm missing the solution (or perhaps is not possible, in which case will be something very nice to have).

In the meantime, I'm sticking to positional arguments :smile:

Thank you
:heart: :heart: :heart:

Most helpful comment

I had a revelation!

I know how a NamedTuple should be, in terms of types and generics. I think it's the "mathematically correct" way (as @waj would say) to model this.

I said that Tuple is Tuple(*T), with T being a tuple of the types.

So, NamedTuple should be NamedTuple(**T), with T being a named tuple of types! :-)

So for example (this syntax doesn't work, of course):

struct NamedTuple(**T)
  def initialize(**args)
    args
  end

  def t
    T
  end

  # other methods will be implemented in a similar way to tuple,
  # with similar compile-time magic that is added for Tuple
end

tup1 = NamedTuple.new(x: 1, y: 2)
tup2 = {x: 1, y: 2}
tup3 = {y: 2, x: 1}
tup1 == tup2 # =>  true
tup1 == tup3 # => true
tup1.class # => NamedTuple(x: Int32, y: Int32)

# Order of names don't matter, we could sort them lexicographically
tup3.class # => NamedTuple(x: Int32, y: Int32)

# T is a named tuple, but of types
tup1.t # => {x: Int32, y: Int32}

# Compare with a Tuple:
struct Tuple
  def t
    T
  end
end

tup = {1, 2}

tup.class # => Tuple(Int32, Int32)

# T is a tuple, but of types
tup.t # => {Int32, Int32}

Now I really want to implement this. It will make the language closer to complete in many ways. For example, the delegate macro could work with named arguments too. A method argument like *args would also catch the named tuple that represents named arguments, similar to Ruby. Let's see:

# This is Ruby
def foo(*args)
  p args # => [1, 2, 3, 4, {:w=>1, :h=>2}]
end

foo(1, 2, 3, 4, w: 1, h: 2)

Note that the last element in args is a Hash in Ruby. In Crystal it would be a NamedTuple. We could forward it just fine:

# This is Ruby
def foo(*args)
  bar(*args)
end

def bar(x, y, w:, h:)
end

foo(1, 2, w: 3, h: 4)

# This is Crystal
def foo(*args)
  bar(*args)
end

def bar(x, y, w, h)
end

foo(1, 2, w: 3, h: 4) # OK

And of course we would do a similar thing in macros.

The only remaining bit, to have forwarding complete, would be to also do it for blocks... somehow. I believe it's possible, just not with how things are implemented right now.

All 9 comments

Right now it's not possible.

We have an idea for this. A Tuple would be the compile-time equivalent of an Array: its size and the types in each position are known. A NamedTuple (or maybe another name) would be the compile-time equivalent of a Hash: it's keys are known, and the type for each key are also known.

Named tuples were first introduced here. If tuples are changed to the parentheses syntax they'll be something like (question: "Life", answer: "42"). However, I don't think we'll make that change, because parentheses can be confusing in proc types (does (Int32, Float64) -> Int32 receive a tuple, or two arguments?). So, one idea would be to use this syntax: {question: "Life", answer: "42"}. Of course that conflicts with hashes with symbols as keys, but maybe we can make that change as it makes sense: hashes with symbols make more sense in Ruby, not so much in Crystal (and if you need them you can do {:question => "Life"}).

With that in place, you could do this:

args = {question: "Life", answer: "42"} # NamedTuple
Question.call(**args) # double star splats a named tuple, and works

Of course in the method side you could use a double splat too:

def forward(*args, **named_args)
  method(*args, **named_args)
end

Or:

def method(**args)
  puts "#{args.question}? #{args.answer}"
end

method(question: "Life", answer: "42") # OK
method(foo: "Bar") # Compile time error

I still don't know how to fully represent a NamedTuple in a generic way (Tuple is Tuple(*T), with T being a tuple of types, I don't know what the equivalent would be for NamedTuple) nor when or if we'll implement this, but I'd like to have it for 1.0. We could also use this for macros, of course, and then we _could_ change JSON.mapping if we wanted to:

module JSON
  macro mapping(**args) # in a macro args can be a HashLiteral (simpler)
    {% for key, value in args %}
      # ...
    {% end %} 
  end
end

struct Point
  JSON.mapping x: Int32, y: Int32
end

In fact, for macros this could be implemented right away (macros are much simpler to implement then other parts of the language), so that will kind of force us to eventually have the same functionality for methods :-P

I had a revelation!

I know how a NamedTuple should be, in terms of types and generics. I think it's the "mathematically correct" way (as @waj would say) to model this.

I said that Tuple is Tuple(*T), with T being a tuple of the types.

So, NamedTuple should be NamedTuple(**T), with T being a named tuple of types! :-)

So for example (this syntax doesn't work, of course):

struct NamedTuple(**T)
  def initialize(**args)
    args
  end

  def t
    T
  end

  # other methods will be implemented in a similar way to tuple,
  # with similar compile-time magic that is added for Tuple
end

tup1 = NamedTuple.new(x: 1, y: 2)
tup2 = {x: 1, y: 2}
tup3 = {y: 2, x: 1}
tup1 == tup2 # =>  true
tup1 == tup3 # => true
tup1.class # => NamedTuple(x: Int32, y: Int32)

# Order of names don't matter, we could sort them lexicographically
tup3.class # => NamedTuple(x: Int32, y: Int32)

# T is a named tuple, but of types
tup1.t # => {x: Int32, y: Int32}

# Compare with a Tuple:
struct Tuple
  def t
    T
  end
end

tup = {1, 2}

tup.class # => Tuple(Int32, Int32)

# T is a tuple, but of types
tup.t # => {Int32, Int32}

Now I really want to implement this. It will make the language closer to complete in many ways. For example, the delegate macro could work with named arguments too. A method argument like *args would also catch the named tuple that represents named arguments, similar to Ruby. Let's see:

# This is Ruby
def foo(*args)
  p args # => [1, 2, 3, 4, {:w=>1, :h=>2}]
end

foo(1, 2, 3, 4, w: 1, h: 2)

Note that the last element in args is a Hash in Ruby. In Crystal it would be a NamedTuple. We could forward it just fine:

# This is Ruby
def foo(*args)
  bar(*args)
end

def bar(x, y, w:, h:)
end

foo(1, 2, w: 3, h: 4)

# This is Crystal
def foo(*args)
  bar(*args)
end

def bar(x, y, w, h)
end

foo(1, 2, w: 3, h: 4) # OK

And of course we would do a similar thing in macros.

The only remaining bit, to have forwarding complete, would be to also do it for blocks... somehow. I believe it's possible, just not with how things are implemented right now.

I had a revelation!

:smile:

I'm getting excited about named tuples now :wink:

Thank you folks for your continuous work on Crystal!

@luislavena I forgot to ask: are all the new additions/changes useful for your original use case? (I hope so!)

@asterite they do! :tada: :smile:

Closing this! Thank you!
:heart: :heart: :heart:

Is worth mentioning that I've added two self.call methods, one for positional and another for named parameters, so Question.call("Life", 42) and Question.call(answer: 42, question: "Life") both works.

class Question
  def self.call(*args)
    new(*args)
  end

  def self.call(**args)
    new(**args)
  end
 end

You can also do this:

class Question
  def self.call(*args, **nargs)
    new(*args, **nargs)
  end
end

Ah, but I guess your way it's different, because you either require all to be positional, or all to be named. Interesting :-)

Yes, I wanted to avoid mixing positional and named parameters, but then your approach provide a more generic way (to be inherited by other classes), and once #2590 gets in, will make things more easy to define what can be positional and what needs to be named.

This self.call is just a helper to avoid doing:

question = Question.new(...)
question.call
result = question.result

I can only foresee some neat DSL with this... and this being check and compiled... damn! (insert here mindblown GIF)

See? Crystal (and you folks) keep giving me great surprises! :tada: :smile:

Was this page helpful?
0 / 5 - 0 ratings

Related issues

xtagon picture xtagon  路  132Comments

akzhan picture akzhan  路  67Comments

chocolateboy picture chocolateboy  路  87Comments

malte-v picture malte-v  路  77Comments

MakeNowJust picture MakeNowJust  路  64Comments