GitHub release Build Status License

dataclass

A Crystal macro to ease the definition of data classes, i.e. classes whose main purpose is to hold data.

Data class instances are immutable, and provide a natural implementation for the most common methods. They also define some basic pattern matching functionality, to ease data extraction.

Follow the links to read more about data classes in Kotlin, Scala and Python.

Installation

Add this to your application's shard.yml:

dependencies:
  dataclass:
    github: lbarasti/dataclass

Usage

require "dataclass"

Let's define a class with read-only fields

dataclass Person{name : String, age : Int = 18}

We can now create instances and access fields

p = Person.new("Rick", 28)

p.name # => "Rick"
p.age # => 28

The equality operator is defined to perform structural comparison

q = Person.new("Rick", 28)

p == q # => true

The hash method is defined accordingly. This guarantees predictable behaviour with Set and Hash.

  visitors = Set(Person).new
  visitors << p
  visitors << q

  visitors.size # => 1

to_s is also defined to provide a human readable string representation for a data class instance

puts p # prints "Person(Rick, 28)"

Instances of a data class are immutable. A copy method is provided to build new versions of a given object

p.copy(age: p.age + 1) # => Person(Rick, 29)

Pattern-based parameter extraction

Data classes enable you to extract parameters using some sort of pattern matching. This is powered by a custom definition of the []= operator on the data class itself.

For example, given the data classes

dataclass Person{name : String, age : Int = 18}
dataclass Address{line1 : String, postcode : String}
dataclass Profile{person : Person, address : Address}

and a Profile instance profile

profile = Profile.new(Person.new("Alice", 43), Address.new("10 Strand", "EC1"))

the following is supported

age, postcode = nil, nil
Profile[Person[_, age], Address[_, postcode]] = profile

age == profile.person.age # => true
postcode == profile.address.postcode # => true

Note that it is necessary for the variables used in the pattern matching to be initialized before they appear in the pattern.

Skipping the initialization step will produce a compilation error as soon as you try to reuse such variables.

Destructuring assignment

Data classes support destructuring assignment. There is no magic involved here: data classes simply implement the indexing operator #[](idx).

person, address = profile

person == profile.person # => true
address == profile.address # => true

The inconvenience with this approach is that the type of both person and address at compile time is going to be String | Int32. This might make your code a bit uglier than it needs to be.

To circumvent this limitation, the to_tuple method is also provided. This assigns the right type to each extracted parameter even at compile-time

profile.to_tuple # => {Person(...), Address(...)}

person, address = profile.to_tuple

person == profile.person # => true
address == profile.address # => true

The macro also defines a to_named_tuple method, which provides a natural transformation of your data class instance to NamedTuple

  person.to_named_tuple # => {"name": "Alice", "age": 43}

Mind that, by design, both to_tuple and to_named_tuple are not recursive - so they will not convert data class fields to tuples / named tuples respectively.

Support for inheritance

The dataclass macro supports inheritance, so the following code is valid

class Vehicle; end
dataclass Car{passengers : Int16} < Vehicle

Support for type parameters

The dataclass macro supports type parameters, so the following code is valid

dataclass Wrapper(T){value : T}

Support for defining additional methods

The dataclass macro supports defining additional methods on your data class. If you pass the dataclass macro a code block, the body of the code block will be pasted into body of the expanded class definition.

dataclass Person{name : String} do
  def hello
    "Hello #{@name}"
  end
end

Person.new("Matt").hello  # => "Hello Matt"

This also works in conjunction with inheritance.

Under the hood

The dataclass macro expands such that following definitions are equivalent.

dataclass Person{name : String, age : Int = 18} < OtherType do
  def hello
    "Hello #{@name}"
  end
end
class Person < OtherType
  getter(name)
  getter(age)

  def initialize(@name : String, @age : Int = 18)
  end

  def hello
    "Hello #{@name}"
  end

  def_equals_and_hash(@name, @age)

  def copy(name = @name, age = @age) : Person
    Person.new(name, age)
  end

  def [](idx)
    [@name, @age][idx]
  end

  def to_tuple
    {@name, @age}
  end

  def to_named_tuple
    {name: @name, age: @age}
  end

  def to_s(io)
    fields = [@name, @age]
    io << "#{self.class}(#{fields.join(", ")})"
  end
end

Known Limitations

dataclass A{id : String}
dataclass B{id : String, extra : Int32} < A # => won't compile

This is by design. Try defining your data classes so that they inherit from a common abstract class instead.

Development

To expand the macro

crystal tool expand -c <path/to/file.cr>:<line>:<col> <path/to/file.cr>

Contributing

  1. Fork it ( https://github.com/lbarasti/dataclass/fork )
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request

Contributors