EK9 Design Patterns

Many design patterns are built into EK9 and covered in their own sections. This page covers patterns that most languages need. Some languages add a feature to support such a pattern, and some of those features cause defects or make code hard to understand. The patterns here work in EK9, and in other languages too.

Object Initialisation

Many object-oriented languages require an object to be configured into a stable, fully initialised state before it is used. That is generally a good idea, but it leads to constructors with many arguments, or to several constructors with different numbers of arguments.

To cope with the second, some languages added default argument values. EK9 does not have them; the alternative follows below.

Argument Passing

If EK9 had default argument values, a declaration would look like this. It does not: a value after a parameter is a syntax error.

SomeMethodOrConstructor()
  ->
    name as String: "Steve"
    developer as Boolean: true
    dogOwner as Boolean: false

In a language that does support default values, calls to it would look like this.

result1 <- SomeMethodOrConstructor()
result2 <- SomeMethodOrConstructor("Stephen")
result3 <- SomeMethodOrConstructor(false)
result4 <- SomeMethodOrConstructor("Bill", false)
result5 <- SomeMethodOrConstructor("Ted", false, true)

//Named arguments say which Boolean is meant.
//Without them, a language matches the first Boolean to the first Boolean parameter and defaults the second.
result6 <- SomeMethodOrConstructor(name: "Stephen")
result7 <- SomeMethodOrConstructor(dogOwner: false)
result8 <- SomeMethodOrConstructor("Bill", developer: false)
result9 <- SomeMethodOrConstructor("Ted", false, dogOwner: true)

This has some appeal, but it brings ambiguity and complexity. What if a named argument is given more than once? What if some arguments are named and others are not? How does it work with varargs?

EK9 does have named arguments, but no default values, and a call names either all its arguments or none of them (E06240); a name must match the parameter's (E06250). Of the calls above, only result5 would compile in EK9: the others leave out arguments that have no default, and result8 and result9 also name only some of their arguments.

Some Java developers use what they call a 'Builder Pattern' (not the Builder pattern from the Gang of Four design patterns book). It duplicates all the properties so they can be mutated, then creates the final object from that mutated state.

The Go community has more reasonable approaches. EK9's is similar to those, and to mechanisms common in Haskell code bases.

EK9 Approach

The EK9 pattern, which also works in a pure context, is:

Compile-time enforcement: when three or more callables share four or more parameters with matching types and names, the compiler reports a data clump (E11053). The code does not compile until the group becomes a record.

That raises the question of how to create the record, especially in a library. The answer is delegated construction:

  1. A single default constructor allocates every property but leaves it unset.
  2. One or more pure functions set the properties that are still unset.
  3. Last, a defaulting function sets whatever is still unset.

Alternatively, copy the values from an existing, initialised object and then modify the copy; an example follows.

EK9 library code example

This 'library' class performs a service, and can be configured to work in slightly different ways.

defines record

  ConfigurationDetails
    name <- String()
    useMapping <- Boolean()
    frequencyOfChecks <- Integer()

    default ConfigurationDetails() as pure

    ConfigurationDetails() as pure
      -> initializer as Consumer of ConfigurationDetails
      initializer(this)

    ConfigurationDetails() as pure
      -> copyFrom as ConfigurationDetails
      this :=: copyFrom

    ConfigurationDetails() as pure
      ->
        initializer as Consumer of ConfigurationDetails
        copyFrom as ConfigurationDetails

      initializer(this)
      this :=: copyFrom

    operator :=:
      -> copyFrom as ConfigurationDetails

      name :=? String(copyFrom.name)
      useMapping :=? Boolean(copyFrom.useMapping)
      frequencyOfChecks :=? Integer(copyFrom.frequencyOfChecks)

    default operator ?
    default operator $

defines class

  SomeConfigurableService

    configuration as ConfigurationDetails: ConfigurationDetails()

    //With no configuration given, the library's defaults are used.
    SomeConfigurableService() as pure
      configuration :=? ConfigurationDetails(defaultConfiguration)

    SomeConfigurableService() as pure
      -> useConfiguration as ConfigurationDetails
      configuration :=? ConfigurationDetails(useConfiguration)

    underTakeServiceOperations() as pure
      stdout <- Stdout()
      if configuration.name?
        stdout.println(`Processing Service operation for ${configuration.name}`)
      if configuration.useMapping
        stdout.println(`Will use mapping`)
      if configuration.frequencyOfChecks?
        stdout.println(`Processing Service frequency ${configuration.frequencyOfChecks}`)

    configurationDetails() as pure
      <- rtn as String: $configuration

    default operator ?

The properties that would normally sit in the SomeConfigurableService class are pulled out into a separate ConfigurationDetails record. That is the first change: isolate the aggregate data.

The second: ConfigurationDetails can be constructed with memory allocated for its properties but their values unset. EK9's language features work well with that.

The third: the constructors (all pure) can change the record's properties while they are still unset. This is how EK9 implements pure. It is arguably not totally pure, but a constructor has to initialise its properties, or delegate that. So there are four forms of constructor:

You may not need all four, but as a library provider it is good to give your developers the flexibility.

Delegating to a Consumer of ConfigurationDetails is what removes the need for many overloaded constructors. There are only three properties here; imagine a data transfer object with twenty.

The library also provides the functions below. Each takes a single Acceptor or Consumer of ConfigurationDetails, or a List of them. An Acceptor may be used in a context that is not pure; a Consumer is pure, so it can be used in a pure context.

defines function

  //The fallback: sets only what nothing else has set. Each builder below applies it last.
  defaultConfiguration() as pure
    -> details as ConfigurationDetails
    details.name :=? "default"
    details.useMapping :=? false
    details.frequencyOfChecks :=? 6

  applyConfigurations()
    -> changes as List of Acceptor of ConfigurationDetails
    <- configuration as ConfigurationDetails: ConfigurationDetails()
    for change in changes
      change(configuration)
    defaultConfiguration(configuration)

  applyConfiguration()
    -> change as Acceptor of ConfigurationDetails
    <- configuration as ConfigurationDetails: ConfigurationDetails()
    change(configuration)
    defaultConfiguration(configuration)

  conditionallyApplyConfigurations() as pure
    -> changes as List of Consumer of ConfigurationDetails
    <- configuration as ConfigurationDetails: ConfigurationDetails()
    for change in changes
      change(configuration)
    defaultConfiguration(configuration)

  conditionallyApplyConfiguration() as pure
    -> change as Consumer of ConfigurationDetails
    <- configuration as ConfigurationDetails: ConfigurationDetails()
    change(configuration)
    defaultConfiguration(configuration)

These functions, or functions like them, 'build' a ConfigurationDetails record. defaultConfiguration is the fallback: it gives reasonable defaults for anything a bespoke configuration did not set. Each function applies it last, and the no-argument SomeConfigurableService constructor builds its configuration from it alone. Because defaultConfiguration only uses :=? it can be pure, and so a Consumer usable in a pure context. A plain assignment in a pure scope is rejected (E08100).

EK9 client code example

Here is how a 'client' uses the 'library'.

references
  site.commonDesignPatterns.library::applyConfigurations
  site.commonDesignPatterns.library::conditionallyApplyConfiguration
  site.commonDesignPatterns.library::ConfigurationDetails
  site.commonDesignPatterns.library::SomeConfigurableService

defines function

  testAPlainConfiguration() as pure

    someServer <- SomeConfigurableService()
    someServer.underTakeServiceOperations()

    asConfigured <- someServer.configurationDetails()
    require asConfigured?

  withSpecificValues() as pure
    -> details as ConfigurationDetails
    details.name :=? "Specific Value"
    details.useMapping :=? false
    details.frequencyOfChecks :=? 21

  testABespokePureConfiguration1() as pure

    someServer <- SomeConfigurableService(conditionallyApplyConfiguration(withSpecificValues))
    someServer.underTakeServiceOperations()

    asConfigured <- someServer.configurationDetails()
    require asConfigured?

That callback is what removes the need for large, complex 'builder' classes and for many overloaded constructor methods in ConfigurationDetails.

The next example shows how flexible this is. For a few properties you would not need it; with many properties and required final default values it works well, through composition.

defines function

  withSpecificName()
    -> details as ConfigurationDetails
    details.name: "specific"

  enableMapping()
    -> details as ConfigurationDetails
    details.useMapping: true

  testAFlexibleBespokeConfiguration()

    someServer <- SomeConfigurableService(
      applyConfigurations(
        [ withSpecificName, enableMapping ]
        )
      )

    someServer.underTakeServiceOperations()

    asConfigured <- someServer.configurationDetails()
    require asConfigured?

  testACopyAndInitialisePureConfiguration() as pure

    specifics <- () is Consumer of ConfigurationDetails as pure function
      t.name :=? "Retained Value"
      t.useMapping :=? false

    configuration <- ConfigurationDetails()
    configuration.name :=? "Ignored Value"
    configuration.frequencyOfChecks :=? 16

    someServer <- SomeConfigurableService(ConfigurationDetails(specifics, configuration))

    someServer.underTakeServiceOperations()

    asConfigured <- someServer.configurationDetails()

    //asConfigured would be 'name = "Retained Value", useMapping = false, frequencyOfChecks = 16'
    require asConfigured?

A program in the full source calls the four functions in turn; it prints:

Processing Service operation for default
Processing Service frequency 6
Processing Service operation for Specific Value
Processing Service frequency 21
Processing Service operation for specific
Will use mapping
Processing Service frequency 6
Processing Service operation for Retained Value
Processing Service frequency 16

With this approach you can create a set of specific configurations and compose them in different ways. For a handful of properties it is not worth it; for a record with many properties it gives a flexible way to create objects, even in an immutable or pure context.

Summary

Why EK9 does it this way. EK9 has the pure keyword and limits mutation, so it needs a flexible and safe way to create immutable objects in an immutable context; constructing an object always needs some mutation of its property values. The Consumer type and the :=? (assign if unset) operator give a controlled, modular way to construct objects that scales. For that reason, and because of the problems default argument values have caused in other languages, EK9 has no default argument values.

Next Steps

Packaging covers packaging your code and publishing it to an artefact server. For the command line parameters, see command line.