Module: Functional::Record

Extended by:
Record
Included in:
Record
Defined in:
lib/functional/record.rb

Overview

Note:

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.

See Also:

Class Method Summary (collapse)

Instance Method Summary (collapse)

Class Method Details

+ (Functional::AbstractStruct) new(*fields, &block)

Create a new record class with the given fields.

Returns:

  • (Functional::AbstractStruct)

    the new record subclass

Raises:

  • (ArgumentError)

    no fields specified or an invalid type specification is given



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.

Returns:

  • (Functional::AbstractStruct)

    the new record subclass

Raises:

  • (ArgumentError)

    no fields specified or an invalid type specification is given



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