Module: Functional::Record
Overview
This is a write-once, read-many, thread safe object that can be used in concurrent systems. Thread safety guarantees cannot be made about objects contained within this object, however. Ruby variables are mutable references to mutable objects. This cannot be changed. The best practice it to only encapsulate immutable, frozen, or thread safe objects. Ultimately, thread safety is the responsibility of the programmer.
An immutable data structure with multiple data fields. A Record
is a
convenient way to bundle a number of field attributes together,
using accessor methods, without having to write an explicit class.
The Record
module generates new AbstractStruct
subclasses that hold a
set of fields with a reader method for each field.
A Record
is very similar to a Ruby Struct
and shares many of its behaviors
and attributes. Unlike a # Ruby Struct
, a Record
is immutable: its values
are set at construction and can never be changed. Divergence between the two
classes derive from this core difference.
Declaration
A Record
class is declared in a manner identical to that used with Ruby's Struct
.
The class method new
is called with a list of one or more field names (symbols).
A new class will then be dynamically generated along with the necessary reader
attributes, one for each field. The newly created class will be anonymous and
will mixin Functional::AbstractStruct
. The best practice is to assign the newly
created record class to a constant:
Customer = Functional::Record.new(:name, :address) => Customer
Alternatively, the name of the record class, as a string, can be given as the
first parameter. In this case the new record class will be created as a constant
within the Record
module:
Functional::Record.new("Customer", :name, :address) => Functional::Record::Customer
Type Specification
Unlike a Ruby Struct
, a Record
may be declared with a type/protocol
specification. In this case, all data members are checked against the
specification whenever a new record is created. Declaring a Record
with a
type specification is similar to declaring a normal Record
, except that
the field list is given as a hash with field names as the keys and a class or
protocol as the values.
Functional::SpecifyProtocol(:Name) do
attr_reader :first
attr_reader :middle
attr_reader :last
end
TypedCustomer = Functional::Record.new(name: :Name, address: String) => TypedCustomer
Functional::Record.new("TypedCustomer", name: :Name, address: String) => Functional::Record::TypedCustomer
Construction
Construction of a new object from a record is slightly different than for a Ruby Struct
.
The constructor for a struct class may take zero or more field values and will use those
values to popuate the fields. The values passed to the constructor are assumed to be in
the same order as the fields were defined. This works for a struct because it is
mutable--the field values may be changed after instanciation. Therefore it is not
necessary to provide all values to a stuct at creation. This is not the case for a
record. A record is immutable. The values for all its fields must be set at instanciation
because they cannot be changed later. When creating a new record object the constructor
will accept a collection of field/value pairs in hash syntax and will create the new
record with the given values:
Customer.new(name: 'Dave', address: '123 Main')
=> <record Customer :name=>"Dave", :address=>"123 Main">
Functional::Record::Customer.new(name: 'Dave', address: '123 Main')
=> <record Functional::Record::Customer :name=>"Dave", :address=>"123 Main">
When a record is defined with a type/protocol specification, the values of all non-nil data members are checked against the specification. Any data value that is not of the given type or does not satisfy the given protocol will cause an exception to be raised:
class Name
attr_reader :first, :middle, :last
def initialize(first, middle, last)
@first = first
@middle = middle
@last = last
end
end
name = Name.new('Douglas', nil, 'Adams') => <Name:0x007fc8b951a278 ...
TypedCustomer.new(name: name, address: '123 Main') => <record TypedCustomer :name=><Name:0x007f914cce05b0 ...
TypedCustomer.new(name: 'Douglas Adams', address: '123 Main') => ArgumentError: 'name' must stasify the protocol :Name
TypedCustomer.new(name: name, address: 42) => ArgumentError: 'address' must be of type String
Default Values
By default, all record fields are set to nil
at instanciation unless explicity set
via the constructor. It is possible to specify default values other than nil
for
zero or more of the fields when a new record class is created. The new
method of
Record
accepts a block which can be used to declare new default values:
Address = Functional::Record.new(:street_line_1, :street_line_2,
:city, :state, :postal_code, :country) do
default :state, 'Ohio'
default :country, 'USA'
end
=> Address
When a new object is created from a record class with explicit default values, those values will be used for the appropriate fields when no other value is given at construction:
Address.new(street_line_1: '2401 Ontario St',
city: 'Cleveland', postal_code: 44115)
=> <record Address :street_line_1=>"2401 Ontario St", :street_line_2=>nil, :city=>"Cleveland", :state=>"Ohio", :postal_code=>44115, :country=>"USA">
Of course, if a value for a field is given at construction that value will be used instead of the custom default:
Address.new(street_line_1: '1060 W Addison St',
city: 'Chicago', state: 'Illinois', postal_code: 60613)
=> <record Address :street_line_1=>"1060 W Addison St", :street_line_2=>nil, :city=>"Chicago", :state=>"Illinois", :postal_code=>60613, :country=>"USA">
Mandatory Fields
By default, all record fields are optional. It is perfectly legal for a record
object to exist with all its fields set to nil
. During declaration of a new record
class the block passed to Record.new
can also be used to indicate which fields
are mandatory. When a new object is created from a record with mandatory fields
an exception will be thrown if any of those fields are nil:
Name = Functional::Record.new(:first, :middle, :last, :suffix) do
mandatory :first, :last
end
=> Name
Name.new(first: 'Joe', last: 'Armstrong')
=> <record Name :first=>"Joe", :middle=>nil, :last=>"Armstrong", :suffix=>nil>
Name.new(first: 'Matz') => ArgumentError: mandatory fields must not be nil
Of course, declarations for default values and mandatory fields may be used together:
Person = Functional::Record.new(:first_name, :middle_name, :last_name,
:street_line_1, :street_line_2,
:city, :state, :postal_code, :country) do
mandatory :first_name, :last_name
mandatory :country
default :state, 'Ohio'
default :country, 'USA'
end
=> Person
Default Value Memoization
Note that the block provided to Record.new
is processed once and only once
when the new record class is declared. Thereafter the results are memoized
and copied (via clone
, unless uncloneable) each time a new record object
is created. Default values should be simple types like String
, Fixnum
,
and Boolean
. If complex operations need performed when setting default
values the a Class
should be used instead of a Record
.
Why Declaration Differs from Ruby's Struct
Those familiar with Ruby's Struct
class will notice one important
difference when declaring a Record
: the block passes to new
cannot be
used to define additional methods. When declaring a new class created from a
Ruby Struct
the block can perform any additional class definition that
could be done had the class be defined normally. The excellent
Values supports this same behavior.
Record
does not allow additional class definitions during declaration for
one simple reason: doing so violates two very important tenets of functional
programming. Specifically, immutability and the separation of data from
operations.
Record
exists for the purpose of creating immutable objects. If additional
instance methods were to be defined on a record class it would be possible
to violate immutability. Not only could additional, mutable state be added
to the class, but the existing immutable attributes could be overridden by
mutable methods. The security of providing an immutable object would be
completely shattered, thus defeating the original purpose of the record
class. Of course it would be possible to allow this feature and trust the
programmer to not violate the intended immutability of class, but opening
Record
to the possibility of immutability violation is unnecessary and
unwise.
More important than the potential for immutability violations is the fact the adding additional methods to a record violates the principal of separating data from operations on that data. This is one of the core ideas in functional programming. Data is defined in pure structures that contain no behavior and operations on that data are provided by polymorphic functions. This may seem counterintuitive to object oriented programmers, but that is the nature of functional programming. Adding behavior to a record, even when that behavior does not violate immutability, is still anathema to functional programming, and it is why records in languages like Erlang and Clojure do not have functions defined within them.
Should additional methods need defined on a Record
class, the appropriate
practice is to declare the record class then declare another class which
extends the record. The record class remains pure data and the subclass
contains additional operations on that data.
NameRecord = Functional::Record.new(:first, :middle, :last, :suffix) do
mandatory :first, :last
end
class Name < NameRecord
def full_name
"{first} {last}"
end
def formal_name
name = [first, middle, last].select{|s| ! s.to_s.empty?}.join(' ')
suffix.to_s.empty? ? name : name + ", {suffix}"
end
end
jerry = Name.new(first: 'Jerry', last: "D'Antonio")
ted = Name.new(first: 'Ted', middle: 'Theodore', last: 'Logan', suffix: 'Esq.')
jerry.formal_name => "Jerry D'Antonio"
ted.formal_name => "Ted Theodore Logan, Esq."
Inspiration
Neither struct nor records are new to computing. Both have been around for a very long time. Mutable structs can be found in many languages including Ruby, Go, C, and C, just to name a few. Immutable records exist primarily in functional languages like Haskell, Clojure, and Erlang. The inspiration for declaring records with a type specification is taken from PureScript, a compile-to-JavaScript language inspired by Haskell.
Class Method Summary (collapse)
-
+ (Functional::AbstractStruct) new(*fields, &block)
Create a new record class with the given fields.
Instance Method Summary (collapse)
-
- (Functional::AbstractStruct) new(*fields, &block)
Create a new record class with the given fields.
Class Method Details
+ (Functional::AbstractStruct) new(*fields, &block)
Create a new record class with the given fields.
33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
# File 'lib/functional/record.rb', line 33 def new(*fields, &block) raise ArgumentError.new('no fields provided') if fields.empty? name = nil types = nil # check if a name for registration is given if fields.first.is_a?(String) name = fields.first fields = fields[1..fields.length-1] end # check for a set of type/protocol specifications if fields.size == 1 && fields.first.respond_to?(:to_h) types = fields.first fields = fields.first.keys check_types!(types) end build(name, fields, types, &block) rescue raise ArgumentError.new('invalid specification') end |
Instance Method Details
- (Functional::AbstractStruct) new(*fields, &block)
Create a new record class with the given fields.
33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
# File 'lib/functional/record.rb', line 33 def new(*fields, &block) raise ArgumentError.new('no fields provided') if fields.empty? name = nil types = nil # check if a name for registration is given if fields.first.is_a?(String) name = fields.first fields = fields[1..fields.length-1] end # check for a set of type/protocol specifications if fields.size == 1 && fields.first.respond_to?(:to_h) types = fields.first fields = fields.first.keys check_types!(types) end build(name, fields, types, &block) rescue raise ArgumentError.new('invalid specification') end |