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:
- A few arguments (say one to four): overload the method.
- Many arguments: group them in a record.
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:
- A single default constructor allocates every property but leaves it unset.
- One or more pure functions set the properties that are still unset.
- 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 ?
Full source: commonDesignPatterns_library.ek9 - Download commonDesignPatterns_library.ek9
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:
- a pure default constructor
- a delegating constructor
- a copy constructor
- a Haskell-like copy-and-delegate 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)
Full source: commonDesignPatterns_library.ek9 - Download commonDesignPatterns_library.ek9
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?
Full source: commonDesignPatterns_client.ek9 - Download commonDesignPatterns_client.ek9
-
testAPlainConfigurationcreatesSomeConfigurableServicewith no configuration, so it runs with the library's defaults. -
testABespokePureConfiguration1passes the client'swithSpecificValuesto the library functionconditionallyApplyConfiguration. The library callswithSpecificValuesback to modify theConfigurationDetails.
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?
Full source: commonDesignPatterns_client.ek9 - Download commonDesignPatterns_client.ek9
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
-
testAFlexibleBespokeConfigurationapplies several configurations, and the library's default set fills in whatever they leave unset (herefrequencyOfChecks). The library gives the client code a modular way to changeConfigurationDetails. -
testACopyAndInitialisePureConfigurationtakes a more Haskell-like approach: it passes an object to copy from and a function that provides the values to override. That function is a dynamic function.
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.