The Programming Language

Crystal 0.17.0 released!

17 May 2016 by asterite

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

comments powered by Disqus