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:
- the same as a Java 14 record
- a database row (though you can use one that way)
- immutable: you can change the data in a record
- a place for methods: implement its operators instead
- a dead end: it can extend another record and be open for extension
A record is:
- in many ways a 'data transfer object'
- allowed to be abstract
- like a C struct, but with operators
- a good way to pass several values in and out of methods and functions
- able to define one or more constructors
- always carrying data. A concrete record must have at least one field, counting the ones it inherits (E07237). Only an abstract record, the root of a record hierarchy, may have none, and only such a field-less root may declare $, $$ or #? abstract. On a record with fields they need a body or default operator, or a sub-record's generated operator would leave those fields out (E07185).
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}`
Download records_data_clump.ek9
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))
- A record suits most data clumps: it gets equality, comparison, hashing and a string form with no boilerplate.
- A class is for when you need behaviour beyond operators.
- A dynamic class may suit a one-off grouping local to a function.
One directive gives a record a set of operators:
//EK9 to provide default implementations default operator
Full source: records_default_operator.ek9 - Download records_default_operator.ek9
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)}`)
Download records_customers.ek9
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.
- Functional programmers: records, functions and pure fit together well. A functional approach would avoid Exceptions and use EK9's unset values, which work well with Streams/Pipelines.
- Object-oriented programmers: operators on a 'DTO' may be useful, and traits and classes are still to come.
Next Steps
The next section shows how functions can be both abstract and polymorphic.