Crystal 0.17.0 has been released!
This release includes a bunch of nice features: named tuples, double splats, a new
algorithm for method arguments, and as?
.
Before introducing named tuples, let’s remember what tuples are.
Tuples
You can think of a tuple as an immutable compile-time equivalent of an Array. The compiler knows its size and the type in each position. A tuple can’t be modified.
# This is an Array
array = [1, "hi"]
array[0] # => 1 (compile-time type is Int32 | String)
array[1] # => "hi" (compile-time type is Int32 | String)
array[2] # IndexError (runtime exception)
# We can change the array
array[0] = 2 # OK
# This is a Tuple
tuple = {1, "hi"}
tuple[0] # => 1 (compile-time type is Int32)
tuple[1] # => "hi" (compile-time type is String)
tuple[2] # compile-time error
# We can't change a tuple
tuple[0] = 2 # undefined method `[]=` for Tuple
Note that in the last access we get a compile time error, because the compiler knows the size of the tuple it knows that that is always going to be an error.
A method can specify a splat argument, and extra call arguments will be places in it, as a Tuple:
def foo(x, y, *other)
# Return the tuple
other
end
# Here 3, "foo" and "bar" are captured in the other
# argument, as a Tuple
other = foo 1, 2, 3, "foo", "bar"
other # : Tuple(Int32, String, String)
A tuple can also be splatted into method arguments:
def foo(x, y)
x - y
end
tup = {10, 3}
# Here we "unpack" the tuple into arguments
foo(*tup) # => 7
A tuple is a struct, and as such it’s allocated on the stack and doesn’t involves heap allocations nor puts pressure on the GC.
A Tuple is a generic type, Tuple(*T)
, with T
being a tuple of types, but that’s the only
special thing about it. It has its own type, documented here
and you can reopen and add methods to it. For example, if you require "json"
you can serialize
a tuple to json:
require "json"
{1, 2}.to_json # => "[1, 2]"
Named tuples
Now that we know the difference between Array and Tuple we are ready to learn about what named tuples are.
You can think of a named tuple as an immutable compile-time equivalent of a Hash, with symbols as its keys. The compiler knows its keys and what type corresponds to each key. A named tuple can’t be modified.
# This is a Hash
hash = {:foo => "hello", :bar => 2}
hash[:foo] # => "hello" (compile-time type is String | Int32)
hash[:bar] # => 2 (compile-time type is String | Int32)
hash[:baz] # KeyError (runtime exception)
# We can change a hash
hash[:foo] = "bye" # OK
# This is a NamedTuple
tuple = {foo: "hello", bar: 2}
tuple[:foo] # => "hello" (compile-time type is String)
tuple[:bar] # => 2 (compile-time type is Int32)
tuple[:baz] # compile-time error
# We can't change a named tuple
tuple[:foo] = "bye" # undefined method `[]=` for NamedTuple
Note: if you come from Ruby, you might know that {foo: "hello"}
denotes a Hash with a symbol
key :foo
and value "hello"
. This is different in Crystal, it denotes a NamedTuple.
Also note that, similar to what happens with tuples, when indexing with a key that’s not present in the named tuple the compiler can give a compile-time error. So, in a way, a named tuple is also a type-safe (or maybe “name-safe”) equivalent of an immutable Hash.
At this point you might be thinking why members are accessed like tuple[:foo]
and not
tuple.foo
. One reason is that a named tuple has methods, like size
, which returns
the number of elements in it, and so {size: 10}.size
would be confusing: is it accessing
the size
value, or is it asking for the number of elements in it? With the hash-like access
there’s no such confusion. The other reason is that in this way a named tuple indeed looks like
an (immutable) Hash, and behaviour is similar to a tuple, where elements are also accessed
in a hash-like (or array-like) way.
The similarities with Tuple continue. We can specify a double splat in a method argument to capture extra named arguments:
def foo(x, y, **other)
# Return the named tuple
other
end
# Here 1 matches x, y matches y, and the rest (z and w)
# go to other
other = foo 1, z: 3, y: 4, w: 5
other # => {z: 3, w: 5}
A named tuple can also be splatted into method arguments:
def foo(x, y)
x - y
end
tup = {y: 3, x: 10}
foo(**tup) # => 7
A named tuple is a struct, and as such it’s allocated on the stack and doesn’t involves heap allocations nor puts pressure on the GC.
A NamedTuple is a generic type, NamedTuple(**T)
, with T
being a named tuple of types,
but that’s the only special thing about it. It has its own type, documented
here and you can reopen and add methods to it.
For example, if you require "json"
you can serialize a named tuple to json:
require "json"
{x: 1, y: 2}.to_json # => %({"x": 1, "y": 2})
Here we can generate a JSON with known keys without having to allocate heap memory for a Hash.
Tuples and named tuples in action
Tuples and named tuples are very useful data structures. They allow you to group values, either by position or by name, in an efficient way, and without you having to declare new types for them, while still preserving type and name safety.
They can also be used to define delegator methods, methods that simply forward all arguments:
def foo(x, y, z)
x*y - z
end
def forwarder(*args, **nargs)
foo(*args, **nargs)
end
forwarder 10, z: 3, y: 2 # => 10*2 - 3 = 17
The new algorithm of method arguments
We were actually going to adopt a different algorithm for matching call arguments to method arguments, in a way similar to Ruby, but BlaXpirit suggested that we might be interested in how Python 3 works in this regard. And so we decided to implement it in a way very similar to that.
With this we want to say that we always listen and consider all your proposals and suggestions. Then, of course, we accept those that we think will fit better with the language’s philosophy. So keep your suggestions, critics and comments coming, as they can have a huge impact on the final shape of the language (like what happened with this particular subject.)
The detailed algorithm is explained here, but a few highlights of it include the ability to force arguments to be passed as named arguments, and to overload based on required named arguments. For example:
# Two positional arguments allowed, z must be passed as a named argument
def foo(x, y, *, z)
end
foo 1, 2 # Error, missing argument: z
foo 1, 2, 3 # Error, wrong number of arguments (given 3, expected 2)
foo 1, 2, z: 3 # OK
# This is another overload: because arguments after * must be
# passed by name, they are part of a method's signature.
def foo(x, y, *, w)
end
foo 1, 2, w: 3 # calls the method above
With this, APIs can be crafted with a richer semantic and readablity. For example there’s the spawn
method
in the standard library to spawn a fiber:
spawn do
# work
end
There’s also the spawn
macro that receives a call as an argument and spawn a fiber that invokes that method:
spawn work(1)
We always wanted to have a way to spawn a fiber with a name associated to it. The problem is that spawn(name) {}
would conflict with the macro call above (macros don’t overload based on whether a block was given to them).
We could define spawn_named(name) { }
, but that doesn’t look very nice.
With this new feature we can define it with a required named argument:
def spawn(*, name : String)
# ...
end
spawn(name: "worker") do
# ...
end
There are many other situations where required named arguments allow overloading where just the number of arguments and their type is not enough.
External names
Arguments can now also have an external name associated with them, making it possible to use keywords as named arguments, and to have a small readablity boost:
# OK, but reads odd
def increment(value, by)
value + by
end
# Better: you can use `by` when invoking the method,
# and `amount` inside the method body
def increment(value, by amount)
value + amount
end
increment 1, 2 # => 3
increment 1, by: 2 # => 3
The as? pseudo-method
Similar to as, as?
casts an expression
to a given type, if it’s of that type, and otherwise returns nil
. In a way, it’s a safe cast (as
raises a runtime exception instead of returning nil
):
value = rand < 0.5 ? -3 : nil
result = value.as?(Int32) || 10
value.as?(Int32).try &.abs
Final words
Like in previous releases, this release focuses on better readablity and better expressive power while retaining type and name safety, and performance. The next releases will probably focus more on the language’s stability (there are a couple of bugs related to generic types, nothing that can’t be fixed), and improving the concurrency model.
We’d like to thank everyone for their continued support, be it in the form of pull requests, bug reports, bug fixes, comments, suggestions or donations. There’s no way we could have made it so far without you. Happy Crystalling! <3