Dynamic Classes in EK9

A dynamic class serves a similar purpose to a dynamic function. Unlike a dynamic function, it can be 'named', and a named dynamic class is fully usable as a class, as if you had declared it in the class block.

If you know Java, these look like anonymous classes. They can be used that way, and when named they can be used differently.

Defining a class deep in a function or method body may seem strange at first; the examples below show why it is useful. Note that the name of a dynamic class is 'pulled up' to module scope: unlike a Java inner class, it is visible across the module.

A dynamic class cannot extend a class, but it can have traits. To reuse what a 'base class' would have given you:

  1. Extract a trait from the 'base class' with the signatures you need.
  2. Give the dynamic class that trait, and delegate to an object that implements it.
  3. Provide or override the remaining methods in the dynamic class.

Why EK9 does it this way. The limit is deliberate: it promotes composition over inheritance. It may feel like a shortcoming. Go with the flow of the language to get the most from it; if you find that 'too painful', EK9 is probably not the language for you.

Quality Enforcement: the compiler applies its quality limits to dynamic class methods: cyclomatic complexity at most 45, cognitive complexity 35, nesting depth 6, and 100 statements per method (50 for an operator). Variable names, captured variables and fields included, must be descriptive. Dynamic classes have the same cohesion (LCOM4 at most 8) and coupling (Ce at most 12) limits as other classes. A capture that is never used is an error (E11018). Named dynamic classes are visible at module scope. See Code Quality for the full list.

Examples

The examples below show a named, reusable class.

Tuple like example

This is EK9's version of a Tuple: a dynamic class with no traits. The data captured into the Tuple becomes private, and is exposed only through the public methods defined on the class.

Points to note:

This defines classes in a dynamic but type safe way. If you are not from a dynamic language background, the syntax and arrangement may seem strange at first. It is an alternative way to define a class or tuple, and gives the code a more 'interpreted/dynamic' feel.

#!ek9
defines module site.dynamicClasses.tuple

  defines function
    printer2()
      -> personInfo as ATuple
      Stdout().println(`${ personInfo } but I can access ${personInfo.name()} directly if I wish`)

    //Note how we can just use 'ATuple' in a normal way by creating an instance of it.
    getATuple()
      <- rtn as ATuple: ATuple("John", 1994-01-01, "English")

  defines program
    ShowTupleClass()

      stdout <- Stdout()

      name <- "Steve"
      dateOfBirth <- 1970-01-01

      //Just access a function that returns 'ATuple' - even though 'ATuple' is not defined yet!
      tuple1 <- getATuple()

      //Let's have an inferred List of 'ATuple' and put the first entry in.
      tuples <- [tuple1]

      //This is both a declaration of a new type 'ATuple' and the creation of an object of type 'ATuple'
      //AND also indented is the implementation of 'ATuple'.
      //Plus we can put this in a loop if we need to; 'ATuple' is only defined once!
      for i in 1 ... 10

        //Make the data up from base information and dynamic content.
        firstName <- `${name}-${i}`
        dob <- dateOfBirth + Duration(`P${i}M`)

        //define, populate and get an object of that type
        tuple <- ATuple(name: firstName, dob: dob, language: "English") as class

          //We can also create new properties in the class directly if necessary
          //Rather than just capture.
          message as String: "Dynamic: "

          //You can even define a range of different constructors if you wish.
          //These are not automatically created, developer needs to define the 'interface'
          //That way if the names of variables captured are refactored/renamed, the rest of the code
          //using ATuple can stay the same.
          ATuple()
            ->
              name as String
              dob as Date
              language as String

            this.name = name
            this.dob = dob
            this.language = language

          //Add in methods, hiding internal structure of object or meeting a trait type interface.
          name()
            <- rtn as String: name

          operator $ as pure
            <- rtn as String: `${message} ${name} ${dob} ${language}`
          operator #^ as pure
            <- rtn as String: $this

          default operator ?

        //OK now just add that new tuple into the List of ATuple
        tuples += tuple

      //Display all those tuples.
      cat tuples > stdout

      //just to demonstrate calling a function that has 'ATuple' as a parameter.
      printer1(tuple1)
      printer2(tuple1)
      printer2(getATuple())

  defines function
    printer1()
      -> tuple as ATuple
      stdout <- Stdout()
      stdout.println(`[ ${tuple} ] but I can access [ ${tuple.name()} ] directly if I wish`)

Running it prints:

Dynamic:  John 1994-01-01 English
Dynamic:  Steve-1 1970-02-01 English
Dynamic:  Steve-2 1970-03-01 English
Dynamic:  Steve-3 1970-04-01 English
Dynamic:  Steve-4 1970-05-01 English
Dynamic:  Steve-5 1970-06-01 English
Dynamic:  Steve-6 1970-07-01 English
Dynamic:  Steve-7 1970-08-01 English
Dynamic:  Steve-8 1970-09-01 English
Dynamic:  Steve-9 1970-10-01 English
Dynamic:  Steve-10 1970-11-01 English
[ Dynamic:  John 1994-01-01 English ] but I can access [ John ] directly if I wish
Dynamic:  John 1994-01-01 English but I can access John directly if I wish
Dynamic:  John 1994-01-01 English but I can access John directly if I wish

You could define all your classes dynamically like this. From C++, Java or C# this will seem strange: there, a class is a big deal and may even need its own file.

The captured data is hidden, and users of the dynamic class can only call the public methods it defines. Those methods are type checked and resolved as you would expect, so renaming a method of a dynamic class is a safe refactoring.

It feels like a dynamically typed language, but it is statically typed and checked at compile time.

To capture fixed values into the 'tuple', give each captured value a name. Those names become the properties of the dynamic class.

Composition Dynamic Class Example

Composition shows how classes and traits are composed. With dynamic classes it goes further, and this example uses dynamic functions and dynamic classes together.

#!ek9
defines module site.dynamicClasses.composition

  defines trait

    LenValidator
      validateLength() as abstract
        -> p as String
        <- r as Boolean?

    CharValidator
      validateCharacters() as abstract
        -> p as String
        <- r as Boolean?

    Validator
      validate() as abstract
        -> p as String
        <- r as Boolean?

  defines class

    NoDollarValidator with trait of CharValidator
      override validateCharacters()
        -> p as String
        <- r as Boolean: p? and p not contains "$"

  defines function

    discriminator() as abstract
      -> s as String
      <- rtn as Boolean?

  defines program

    Example()

      min <- 9
      max <- 40
      prohibited <- "@"

      gt <- (min) is discriminator as function
        rtn: length s > min

      lt <- (max) is discriminator
        rtn: length s < max

      val1 <- "Some Value"
      require gt(val1) and lt(val1)
      val2 <- "Short"
      require not gt(val2) and lt(val2)
      val3 <- "1234567890123456789012345678901234567890Long"
      require gt(val3) and not lt(val3)

      lenValidator <- CheckLength(gt, max) with trait of LenValidator as class
        override validateLength()
          -> p as String
          <- r as Boolean: false
          if p?
            r := gt(p) and length p < max //So I can just use gt and lt or mix and match

        default operator ?

      require lenValidator.validateLength(val1)
      require not lenValidator.validateLength(val2)
      require not lenValidator.validateLength(val3)

      checkContent <- CheckCharacter(prohibited) with trait of CharValidator
        override validateCharacters()
          -> p as String
          <- r as Boolean: true
          if p?
            r: p not contains prohibited

        default operator ?

      val4 <- "1234567890@not allowed"

      require checkContent.validateCharacters(val1)
      require checkContent.validateCharacters(val2)
      require checkContent.validateCharacters(val3)
      require not checkContent.validateCharacters(val4)

      validator1 <- (lenValidator, checkContent) with trait of LenValidator by lenValidator, CharValidator by checkContent
        default operator ?

      require validator1.validateCharacters(val1) and validator1.validateLength(val1)
      require validator1.validateCharacters(val2) and not validator1.validateLength(val2)
      require validator1.validateCharacters(val3) and not validator1.validateLength(val3)
      require not validator1.validateCharacters(val4) and validator1.validateLength(val4)

      validator2 <- (validator1, checkContent) with trait of Validator, LenValidator by validator1, CharValidator by checkContent
        override validate()
          -> p as String
          <- r as Boolean: validateLength(p) and validateCharacters(p)

        default operator ?

      require validator2.validate(val1)
      require not validator2.validate(val2)
      require not validator2.validate(val3)
      require not validator2.validate(val4)

      val5 <- "steve"

      validator3 <- (lenValidator) with trait of Validator, LenValidator by lenValidator, CharValidator by charValidator
        charValidator as CharValidator: NoDollarValidator()

        override validate()
          -> p as String
          <- r as Boolean: true
          if p not matches /[S|s]te(?:ven?|phen)/
            r: validateLength(p) and validateCharacters(p)

        default operator ?

      require validator3.validate(val1)
      require not validator3.validate(val2)
      require not validator3.validate(val3)
      require validator3.validate(val4)
      require validator3.validate(val5)

      val6 <- "steve#host"

      validator4 <- (lenValidator) with trait of Validator, LenValidator by lenValidator, CharValidator
        notAllowed as String: "#"

        override validate()
          -> p as String
          <- r as Boolean: validateLength(p) and validateCharacters(p)

        override validateCharacters()
          -> p as String
          <- r as Boolean: false
          if p?
            r := p not contains notAllowed

        default operator ?

      require validator4.validate(val1)
      require not validator4.validate(val2)
      require not validator4.validate(val3)
      require validator4.validate(val4)
      require not validator4.validate(val5)
      require not validator4.validate(val6)

      Stdout().println("Every validator accepted and rejected as expected")

Each require checks one answer; had any failed, the program would stop there. It prints:

Every validator accepted and rejected as expected

The 'Example' program first defines two dynamic functions, 'gt' and 'lt', both extending the abstract function 'discriminator'. They are tested, then captured and used in dynamic classes.

Dynamic Classes

Both are named dynamic classes, so you can use them elsewhere (with the captured variables they hold).

The Composition

'Validator' is deliberately a trait of its own, rather than one that extends 'LenValidator' and 'CharValidator'. Had it extended them, 'LenValidator' would already be a trait of 'Validator', and 'validator2' could not list it again to delegate it: that is error E02090. Each trait is given by one route only.

Every dynamic class that captures variables declares default operator ?: the captured variables become its private properties, and it must say when it is set (E07236).

Summary

Dynamic classes can be short and flexible, and can be reused directly, through higher order functions, or through composition.

Why EK9 does it this way. The 'Single Responsibility Principle' and composition together reduce the need for inheritance as the only way to change behaviour. You define smaller, well defined functions, traits and classes, each with a single responsibility, then compose them in different ways. With polymorphism in functions as well as classes and traits, EK9 promotes reuse and composition of small, well rounded functions and classes.

With syntax for both inheritance/delegation and dynamic class definition, a declaration as short as:
validator1 <- (lenValidator, checkContent) with trait of LenValidator by lenValidator, CharValidator by checkContent
  default operator ?
creates a new class composed from a mix of functions and other classes that implement traits.

Next Steps

The next section, Stream Pipelines, is more functional again: it uses collections and functions together for a range of processing. To stay with simple class-like aggregates, see Text Properties, which collect all the output text of a program or toolkit in one place.