Records in EK9

A record holds structured data with public fields and a fixed set of operators. Structure introduced it; this page works through an example.

Uses for Records

A record is not:

A record is:

Note: a record field cannot be sanitized (E07920). The modifier is valid only on incoming String parameters of methods, functions and constructors (see Input Sanitization). To store cleaned data in a record, mark the constructor's parameter sanitized, or call the InputSanitizer yourself.

Data Transfer Object?

Records suit data processing. The standard types worked example merges 'customer records' from two sources and outputs them. A record can look like an anemic class, and in EK9 it is designed to be one, or nearly: its constructors and operators give it a little behaviour, in a limited and controlled way. If you need more, use a class.

Working on a record's public data with functions is not poor style in EK9; that is what a record is for. EK9 has no immutable, final or const modifier (it does have constants). A pure method or function cannot mutate the data passed to it, so pure is how a data structure stays un-mutated. It also leads you to Streams/Pipelines, and to copying data and altering the copy.

If you prefer full data encapsulation and a rich object model, use classes and components and leave out records.

Why EK9 does it this way. A record is a data structure that functions work on, and EK9 controls mutation with pure rather than with immutable types. The argument, with Alan Perlis's "100 functions on one data structure", is in the design note records are anemic by design.

The Power of Operators

The operators in the example below give the record a fair amount of behaviour, so it is not that anemic. You don't have to write a full set at the start: a minimal approach, adding operators and methods as you need them, often works best.

Why EK9 does it this way. Developers in C++, C# and Scala still give their records and classes a full set of operators; Java has no operators, so fully rounded types fell out of favour there. For long-lived software, a fully rounded set of types pays off. Writing one, with unit tests that show how it is used, is also a clear, self-contained task a senior developer or architect can give a junior one, and finishing it gives a sense of achievement.

Quality Enforcement: the compiler applies quality limits to record constructors and operators: cyclomatic complexity at most 45, cognitive complexity 35, nesting depth 6, and 50 statements for an operator (100 for a method). Names, record fields included, must be descriptive (no temp, flag, data, value, buffer, object), and references must be in alphabetical order. All of this is checked at compile time; see Code Quality.

Solving Data Clumps

A data clump is three or more callables sharing four or more parameters with matching types and names; the compiler rejects it (E11053). Its recommended fix is a record:

Fails with E11053: data clump detected - extract a record to group repeated parameters

#!ek9
defines module bad.site.records.dataclump

  defines function

    //Before: the same four parameters repeated across functions
    formatAddress()
      ->
        street as String
        city as String
        state as String
        zipCode as String
      <- result as String: `${street}, ${city}, ${state} ${zipCode}`

    isComplete()
      ->
        street as String
        city as String
        state as String
        zipCode as String
      <- result as Boolean: street? and city? and state? and zipCode?

    labelFor()
      ->
        street as String
        city as String
        state as String
        zipCode as String
      <- result as String: `${zipCode}: ${street} ${city} ${state}`

Put the four values in a record and each function takes one parameter:

#!ek9
defines module site.records.address

  defines record

    //After: the four values travel together as one record
    Address
      street as String: String()
      city as String: String()
      state as String: String()
      zipCode as String: String()

      Address()
        ->
          street as String
          city as String
          state as String
          zipCode as String
        this.street: street
        this.city: city
        this.state: state
        this.zipCode: zipCode

      default Address()

      default operator ?

  defines function

    formatAddress()
      -> address as Address
      <- result as String: `${address.street}, ${address.city}, ${address.state} ${address.zipCode}`

    isComplete()
      -> address as Address
      <- result as Boolean: address?

    labelFor()
      -> address as Address
      <- result as String: `${address.zipCode}: ${address.street} ${address.city} ${address.state}`

  defines program

    ShowAddress()
      stdout <- Stdout()
      home <- Address(street: "1 High Street", city: "Springfield", state: "IL", zipCode: "62701")
      stdout.println(formatAddress(home))
      stdout.println(`Complete: ${isComplete(home)}`)
      stdout.println(labelFor(home))

One directive gives a record a set of operators:

//EK9 to provide default implementations
default operator

EK9 generates '$', '$$', '#?', '?' and '<=>' from the record's fields - '<=>' where every field can be ordered, else '=='. Write any of them yourself and EK9 adds the ones you left out. A type defines its sameness once: from '<=>' (or '==' for a type that is only equatable) the compiler always generates '==', '<>', '<', '<=', '>' and '>='. Writing those by hand is an error (E07181), and a hand-written sameness needs a hand-written '#?' over the same fields (E07184).

The Example Code

#!ek9
defines module site.records.customers
  defines type

    Index as Integer constrain as
      > 0

  defines function
    defaultDateOfBirth()
      <- rtn as Date: Date()

    //IdRecord's '<=>' (by id) is the sameness of the whole hierarchy; a subtype cannot redefine it.
    //Ordering customers by their details is therefore a comparator function, not a second '<=>'.
    compareByBirthThenName()
      ->
        customer1 as CustomerDetail
        customer2 as CustomerDetail
      <- rtn as Integer: customer1.dateOfBirth <=> customer2.dateOfBirth

      if rtn == 0
        rtn: customer1.lastName <=> customer2.lastName
      if rtn == 0
        rtn: customer1.firstName <=> customer2.firstName

  defines record

    IdRecord as abstract
      //We can support simple inference, but not complex expressions for fields/properties.
      id <- Index()
      createdAt as DateTime: SystemClock().dateTime()

      IdRecord()
        -> id as Index
        require id?
        this.id: id

      operator $ as pure
        <- rtn as String: `${$id} ${$createdAt}`
      override operator ? as pure
        <- rtn as Boolean: id?
      //The sameness of every IdRecord: ==, <>, <, <=, > and >= are generated from it
      operator <=> as pure
        -> item as IdRecord
        <- rtn as Integer: id <=> item.id
      operator #? as pure
        <- rtn as Integer: #? id

    CustomerDetail extends IdRecord
      firstName as String: String()
      lastName as String: String()
      dateOfBirth as Date: defaultDateOfBirth()

      CustomerDetail()
        ->
          id as Index
          firstName as String
          lastName as String

        super(id)
        require firstName?
        require lastName?
        this.firstName: firstName
        this.lastName: lastName

      CustomerDetail()
        ->
          id as Index
          dateOfBirth as Date

        super(id)
        require dateOfBirth?
        this.dateOfBirth: dateOfBirth

      CustomerDetail()
        ->
          id as Index
          firstName as String
          lastName as String
          dateOfBirth as Date

        super(id)
        require firstName?
        require lastName?
        require dateOfBirth?
        this.firstName: firstName
        this.lastName: lastName
        this.dateOfBirth: dateOfBirth

      operator :=:
        -> item as CustomerDetail
        id :=: item.id
        firstName :=: item.firstName
        lastName :=: item.lastName
        dateOfBirth :=: item.dateOfBirth

      operator :~:
        -> item as CustomerDetail
        if not id?
          id :=: item.id
        if not firstName?
          firstName :=: item.firstName
        if not lastName?
          lastName :=: item.lastName
        if not dateOfBirth?
          dateOfBirth :=: item.dateOfBirth

      operator <~> as pure
        -> item as CustomerDetail
        //Use a fuzzy match on string version of date of birth first
        <- rtn as Integer: ($this.dateOfBirth <~> $item.dateOfBirth) +
        (this.lastName <~> item.lastName) +
        (this.firstName <~> item.firstName)

      operator #^ as pure
        <- rtn as String: $this

      override operator $ as pure
        <- rtn as String: `${super.$()} ${firstName} ${lastName} ${$dateOfBirth}`
      override operator ? as pure
        <- rtn as Boolean: super.?() and firstName? and lastName? and dateOfBirth?

  defines program
    ShowCustomerRecords()
      stdout <- Stdout()

      unknownCustomer <- CustomerDetail()
      stdout.println(`A customer with no details is set: ${unknownCustomer?}`)

      customer1 <- CustomerDetail(id: Index(1), firstName: "Gomez", lastName: "Addams", dateOfBirth: 1963-06-08)
      customer2 <- CustomerDetail(id: Index(2), firstName: "Morticia", lastName: "Addams", dateOfBirth: 1965-01-03)
      customer3 <- CustomerDetail(id: Index(3), firstName: "Pugsley", lastName: "Addams", dateOfBirth: 1984-10-21)

      //'<>' is generated from IdRecord's '<=>', so two customers differ by id.
      stdout.println(`Gomez and Morticia are different records: ${customer1 <> customer2}`)

      stdout.println(`Three Addams, ${customer1.firstName} ${customer2.firstName} and ${customer3.firstName}`)
      //Some Addams we've not see before but born on same day or typo of Gomez?
      customer4 <- CustomerDetail(id: Index(4), firstName: "Goetez", lastName: "Addams", dateOfBirth: 1963-06-08)
      comp1 <- compareByBirthThenName(customer4, customer1)
      comp2 <- compareByBirthThenName(customer4, customer2)
      comp3 <- compareByBirthThenName(customer4, customer3)
      stdout.println(`Compares [${comp1} ${comp2} ${comp3}]`)

      best <- customer4 <~> customer1 < customer4 <~> customer2 <- customer1 else customer2
      best: customer4 <~> customer3 < customer4 <~> best <- customer3 else best
      stdout.println(`The best match for ${customer4.firstName} is ${best.firstName}`)

      //full copy of the details
      unknownCustomer :=: best
      //now while objects differ contents are the same.
      stdout.println(`After the copy, compared with Gomez: ${compareByBirthThenName(unknownCustomer, customer1)}`)

      partialCustomer1 <- CustomerDetail(Index(1), "Gomez", "Addams")
      partialCustomer2 <- CustomerDetail(Index(1), 1963-06-08)
      //As both are partial the result of comparing them is unset!
      stdout.println(`Two partial customers compare: ${compareByBirthThenName(partialCustomer1, partialCustomer2)?}`)

      partialCustomer1 :~: partialCustomer2
      //After merging the two should match Gomez.
      stdout.println(`After the merge, compared with Gomez: ${compareByBirthThenName(partialCustomer1, customer1)}`)

Running the program 'ShowCustomerRecords' prints:

A customer with no details is set: false
Gomez and Morticia are different records: true
Three Addams, Gomez Morticia and Pugsley
Compares [-8 -1 -1]
The best match for Goetez is Gomez
After the copy, compared with Gomez: 0
Two partial customers compare: false
After the merge, compared with Gomez: 0

Constraints

Index is a constrained type: an Integer whose value must be greater than zero. It is the type of the 'id' property in 'IdRecord'. Index is a new type: it does not extend Integer, and you cannot pass one where an Integer is expected.

The Records

'IdRecord' has two fields and a few operators. It is abstract, so you cannot create one directly.

'CustomerDetail' has several constructors and a wide range of operators, including copy, merge and fuzzy match (see operators). Its comparison operators come from 'IdRecord': the '<=>' there (by id) is the sameness of the whole hierarchy, so '==', '<>', '<' and the rest are generated from it, and a subtype cannot define a second one (E07182). Ordering customers by their details is a different question, so it is a function, 'compareByBirthThenName', used as a comparator.

Using the CustomerDetail record

The program 'ShowCustomerRecords' uses the record. Note that its fields are public.

Requirements and constraints are not Exceptions

The 'CustomerDetail' constructors use require to insist on their details. A require that is false, like a value that breaks the 'Index' constraint (an id of '-1'), is a Panic: not an Exception. A try/catch cannot catch it, and it stops the program. A literal that breaks a constraint, such as Index(-1), is rejected at compile time (E08260).

The alternative is to leave the objects unset and handle that in the calling code, as the 'CustomerDetail()' with no details shows.

Using the Operators

The program then uses the operators. The fuzzy match (<~>), with a ternary operator, finds the best match for the 'CustomerDetail' with first name 'Goetez'. Finally merge (:~:) combines two partial records, and the result is compared with 'customer1' (Gomez Addams).

Summary

Records are more than plain Data Transfer Objects. A finite set of operators gives them a specific role in a solution.

Next Steps

The next section shows how functions can be both abstract and polymorphic.