An Immutable DataClass for Ruby
Exposes a class factory function Kernel::DataClass
and a module Lab42::DataClass
which can
extend classes to become Data Classes.
Also exposes two tuple classes, Pair
and Triple
Having immutable Objects has many well known advantages that I will not ponder upon in detail here.
One advantage which is of particular interest though is that, as every, modification is in fact the creation of a new object strong contraints on the data can easily be maintained, and this library makes that available to the user.
Therefore we can summarise the features (or not so features, that is for you to decide and you to chose to use or not):
- Immutable with an Interface à la
OpenStruct
- Attributes are predefined and can have default values
- Construction with keyword arguments, exclusively
- Conversion to
Hash
instances (if you must) - Pattern matching exactly like
Hash
instances - Possibility to impose strong constraints on attributes
- Predefined constraints and concise syntax for constraints
- Possibility to impose arbitrary validation (constraints on the whole object)
- Declaration of dependent attributes which are memoized (thank you Immutability)
- Inheritance with mixin of other dataclasses (multiple if you must)
gem install lab42_data_class
With bundler
gem 'lab42_data_class'
In your code
require 'lab42/data_class'
The following specs are executed with the speculate about gem.
Given that we have imported the Lab42
namespace
DataClass = Lab42::DataClass
Given a simple Data Class
class SimpleDataClass
extend DataClass
attributes :a, :b
end
And an instance of it
let(:simple_instance) { SimpleDataClass.new(a: 1, b: 2) }
Then we access the fields
expect(simple_instance.a).to eq(1)
expect(simple_instance.b).to eq(2)
And we convert to a hash
expect(simple_instance.to_h).to eq(a: 1, b: 2)
And we can derive new instances
new_instance = simple_instance.merge(b: 3)
expect(new_instance.to_h).to eq(a: 1, b: 3)
expect(simple_instance.to_h).to eq(a: 1, b: 2)
For detailed speculations please see here
As seen in the speculations above it seems appropriate to declare a Class
and
extend it as we will add quite some code for constraints, derived attributes and validations.
However a more concise Factory Function might still be very useful in some use cases...
Enter Kernel::DataClass
The Function
If there are no Constraints, Derived Attributes, Validation or Inheritance this concise syntax might easily be preferred by many:
Given some example instances like these
let(:my_data_class) { DataClass(:name, email: nil) }
let(:my_instance) { my_data_class.new(name: "robert") }
Then we can access its fields
expect(my_instance.name).to eq("robert")
expect(my_instance[:email]).to be_nil
But we cannot access undefined fields
expect{ my_instance.undefined }.to raise_error(NoMethodError)
And this is even true for the []
syntax
expect{ my_instance[:undefined] }.to raise_error(KeyError)
And we need to provide values to fields without defaults
expect{ my_data_class.new(email: "[email protected]") }
.to raise_error(ArgumentError, "missing initializers for [:name]")
And we can extract the values
expect(my_instance.to_h).to eq(name: "robert", email: nil)
Then my_instance
is frozen:
expect(my_instance).to be_frozen
And we cannot even mute my_instance
by means of metaprogramming
expect{ my_instance.instance_variable_set("@x", nil) }.to raise_error(FrozenError)
Given
let(:other_instance) { my_instance.merge(email: "[email protected]") }
Then we have a new instance with the old instance unchanged
expect(other_instance.to_h).to eq(name: "robert", email: "[email protected]")
expect(my_instance.to_h).to eq(name: "robert", email: nil)
And the new instance is frozen again
expect(other_instance).to be_frozen
For speculations how to add all the other features to the Factory Function syntax please look here
Two special cases of a DataClass
which behave like Tuple
of size 2 and 3 in Elixir
They distinguish themselves from DataClass
classes by accepting only positional arguments, and
cannot be converted to hashes.
These are actually two classes and not class factories as they have a fixed interface , but let us speculate about them to learn what they can do for us.
Given a pair
let(:token) { Pair("12", 12) }
let(:node) { Triple("42", 4, 2) }
Then we can access their elements
expect(token.first).to eq("12")
expect(token.second).to eq(12)
expect(node.first).to eq("42")
expect(node.second).to eq(4)
expect(node.third).to eq(2)
And we can treat them like Indexable
expect(token[1]).to eq(12)
expect(token[-2]).to eq("12")
expect(node[2]).to eq(2)
And convert them to arrays of course
expect(token.to_a).to eq(["12", 12])
expect(node.to_a).to eq(["42", 4, 2])
And they behave like arrays in pattern matching too
token => [str, int]
node => [root, lft, rgt]
expect(str).to eq("12")
expect(int).to eq(12)
expect(root).to eq("42")
expect(lft).to eq(4)
expect(rgt).to eq(2)
And of course the factory functions are equivalent to the constructors
expect(token).to eq(Lab42::Pair.new("12", 12))
expect(node).to eq(Lab42::Triple.new("42", 4, 2))
... in reality return a new object
Given an instance of Pair
let(:original) { Pair(1, 1) }
And one of Triple
let(:xyz) { Triple(1, 1, 1) }
Then
second = original.set_first(2)
third = second.set_second(2)
expect(original).to eq( Pair(1, 1) )
expect(second).to eq(Pair(2, 1))
expect(third).to eq(Pair(2, 2))
And also
second = xyz.set_first(2)
third = second.set_second(2)
fourth = third.set_third(2)
expect(xyz).to eq(Triple(1, 1, 1))
expect(second).to eq(Triple(2, 1, 1))
expect(third).to eq(Triple(2, 2, 1))
expect(fourth).to eq(Triple(2, 2, 2))
A List
is what a list is in Lisp or Elixir it exposes the following API
Given such a list
let(:three) { List(*%w[a b c]) }
Then this becomes really a linked_list
expect(three.car).to eq("a")
expect(three.cdr).to eq(List(*%w[b c]))
For all details please consult the List speculations
Copyright 2022 Robert Dober [email protected]
Apache-2.0 c.f LICENSE