gem install lab42_data_classWith bundler
gem 'lab42_data_class'In your code
require 'lab42/data_class'The lab42_data_class gem exposes the Lab42::DataClass and DataClass constructor function as
well as the Lab42::CheckedClass mixin (the latter since version 0.9.2).
It also exposes two tuple classes, Pair and Triple of type Lab42::DataClass
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
Hashinstances (if you must) - Pattern matching exactly like
Hashinstances - 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)
In some contexts immutability is just too difficult to implement and mutable code can be much easier to read and maintain. It also might be needed for performance.
For these cases (an example would be the gem Doc2Busted) having all modifications be executed within checks of predefined constraints reduces the risk of bugs in the mutable programming style.
Like its immutable sibbling, Lab42::CheckedClass offers:
- Attributes are predefined and can have default values
- Construction with keyword arguments, exclusively
- Conversion to
Hashinstances (if you must) - Pattern matching exactly like
Hashinstances - Possibility to impose strong constraints on attributes
- Predefined constraints and concise syntax for constraints
- Possibility to impose arbitrary validation (constraints on the whole object)
- Not yet: Inheritance with mixin of other dataclasses (multiple if you must)
The following specs are executed with the speculate about gem.
Given that we have imported the Lab42 namespace
DataClass = Lab42::DataClassGiven a simple Data Class
class SimpleDataClass
extend DataClass
attributes :a, :b
endAnd 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)And we can also update values with a block
new_instance = simple_instance.update(:b) { it.succ }
expect(new_instance.to_h).to eq(a: 1, b: 3)
expect(simple_instance.to_h).to eq(a: 1, b: 2)And if we want to create a new instance with values from the old we can use the parameterless form
new_instance = simple_instance.update do
{ a: it.a.succ, b: it.b.pred }
end
expect(new_instance.to_h).to eq(a: 2, b: 1)
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_nilBut 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: "some@mail.org") }
.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_frozenAnd 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: "robert@mail.provider") }Then we have a new instance with the old instance unchanged
expect(other_instance.to_h).to eq(name: "robert", email: "robert@mail.provider")
expect(my_instance.to_h).to eq(name: "robert", email: nil)And the new instance is frozen again
expect(other_instance).to be_frozenFor 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
-
versions < 0.9
Copyright 2022 Robert Dober robert.dober@gmail.com
Apache-2.0
-
versions >= 0.9
Copyright 2025 Robert Dober robert.dober@gmail.com
GNU AFFERO GENERAL PUBLIC LICENSE v3.0 or later c.f LICENSE