EK9 Operators

The section on basics details how to declare variables, add comments and define visibility of functions, methods and fields. It also covers line termination and indentation (code blocks).

This section lists all the operators that EK9 supports and details their meaning. There are quite a few examples on both how to use the operators; but also how to override operators in both records and classes. The operators have been grouped to help explain related ideas. In some cases the operator is used in a slightly different way, depending on the types. Other languages may use different operators, so take care. Specifically and/or has meaning as both in terms of a boolean operator but also in terms of a bitwise operator.

The Operators

There are quite a few operators, most of which will be obvious, but there are a few new ones. EK9 make heavy use of operators as it improves readability and consistency of code.

Types of Operator

There are three broad types of operator, these are:

A quick note on ternary operators, some developers find these expressions a little confusing and resort to if/else statements, EK9 does have some shortcut syntax that is worth reviewing as it may help. The ternary statement above can be read as:
"If a == true then set result to b else set result to c"

What are Operators

Before we get started on each of the groups/types of operator and their specifics; it is worth understanding what we mean by an operator.

The operator is really a shorthand and consistent way of defining a method on an Object - this method can be a symbol or a phrase. The main difference is that the operator looks disconnected from the object itself.

This expression result := a + b could be read as result := a.+(b) but the former is much more natural and clearer (unless you like Lisp; then (+ a b) would be clearer for you).

As an example, the concept of addition will be familiar to everyone and is normally expressed in mathematical terms as the symbol '+'. Well pretty obvious you may think but there is something that has been misused or confused in many programming languages.

In the expression below neither 6 nor 8 is modified.

  • result ← 6 + 8

This lack of modification is as you would expect in mathematical terms. But some software API's use (misuse) a method called add to add something to a value. So the semantics of addition have been altered. An example of this would be Java List add.

The intention here is not to denigrate Java or any other language, but to highlight the value of operators and the retention of semantics (meaning) in conjunction with most peoples long-term memory.

To accomplish adding something to a value in EK9 you would use the '+=' operator. If you used the '+' then a new List would be provided with the contents of the existing List plus the value. See the example below.

  • //Simple list of string values (note this is shorthand for a List and is not an array)
  • aList ← [ "A", "B", "C" ]
  • //aList remains unmodified as does "D"
  • list2 ← aList + "D"
  • //Now modify list2 - but note the different operator
  • list2 += "E"
  •  
  • //Create another list to be used.
  • endList ← [ "X", "Y", "Z" ]
  • //Same syntax and semantics, but different parameters
  • finalList ← list2 + endList
  • //But the result finalList is obvious as ["A", "B", "C", "D", "E", "X", "Y", "Z"]

This discussion around the semantics of add might seem petty or trivial, but it is absolutely critical when developing generic functions and classes as '+' really must have the same meaning irrespective of type. It is also important in terms of readability, most people are familiar with basic mathematics and so it is very important to retain the semantics of '+' (add).

Being able to read code quickly and immediately see what it accomplishes is very valuable.

The example above using the '+' operator with a List and a String, but also with two Lists highlights this point. In both cases it is obvious that the addition of two items will result in a third item containing the contents of both, but the two items being added will remain unaltered.

It is also important not to introduce new and possibly misleading methods like 'addTo', 'addInTo', 'addAll', 'join', 'joinTogether'. When existing operators like '+' and '+=' can be defined and overridden/overloaded with different parameters, but with the same semantics.

There's been quite a bit of thought and research put into this. Employing a developer's long term memory for really fundamental concepts and semantic meaning that common. Employing symbols and ideograms to leverage the visual side of the developers brain and reducing cognitive load.

Focus

It is very easy to become seduced by operators, to override/overload and twist them into contrived meanings. When overriding an operator it is really worth taking the time to ensure it retains its semantics and consistency. But probably more importantly; the simple use of the '+' operator really could have triggered quite a lot of processing. In the case of two Lists it may have triggered the creation of a new List and the full traversal of both of the other two Lists to populate the return list. This amount of processing may not be obvious from just using the '+' operator!

Or the processing might have created a list with just references to the two lists, giving the impression of joining them. The implementation detail is not important here, but the semantics are.

Operators have been created to be used and overridden. If you are creating an API that uses imaginary numbers and you want to express addition then overriding the '+' is not only acceptable it is strongly encouraged. If you are creating some type of collection again using '+' and '+=' to 'add' or 'add to' is strongly encouraged.

Use them in the manner that was intended and when you need to employ your types with generics the solution will be much simpler, if you are from a C++ background this will feel natural - but take care not to misuse operator overriding/overloading.

Compiler-Enforced Return Value Handling

EK9 takes the distinction between computational and mutating operators one step further: the compiler enforces that computational return values are never silently discarded.

Mutating operators (+=, -=, :=:, ++, --) return Void — the side-effect IS the purpose, so there is nothing to discard. Computational operators (+, -, *, /, ==, <>, contains, matches, length) return values because the VALUE is the purpose.

If a computational operator is used as a bare statement with its result discarded, the compiler emits E11050 — because the statement achieves nothing. This is the same principle as Rust's #[must_use] and C++17's [[nodiscard]], but applied automatically to all operators based on their category — no annotations required.

  • //BAD: Computation result goes nowhere — dead code (E11050)
  • price + tax
  • //GOOD: Assign the result
  • total ← price + tax
  • //GOOD: Or use the mutating operator if mutation is the intent
  • price += tax

This same principle extends beyond operators: pure methods with discarded returns (E11051) and discarded Result/Optional returns (E11052) are also compile-time errors. See Quality: Purity, Safety, and Contracts for details.

The Operators themselves

The first group of operators to be described are those that are used for comparison. Most of which will be obvious, but there are a couple that need closer attention (such as the fuzzy match).

Comparison Operators

The first set of operators to be explained are those used in comparisons and checks and return a Boolean value.

Symbolic Boolean Operators

Typically used in a if statement the following operators are used to compare the value of two objects and the result will be a Boolean value of true or false (or not set if either a or b are not set).

The last point above is important, if operands are unset then the result will be unset.

Examples of the comparison operators

  • 1 < 2 //results in true
  • 1 > 2 //results in false
  • 3 >= 2 //results in true
  • 3 <= 2 //results in false
  • 3 <> 3 //results in false
  • 3 != 3 //results in false
  • 3 == 3 //results in true
  • 3 in 2 ... 8 //results in true
  • 6 not in 2 ... 8 //results in false
Comparison Phrase Operators

The following two operators are full words (phrases) rather than symbols that return a Boolean, these are:

Examples of use are:

  • "Steve" contains "Te" //results in false
  • "Steve" contains "te" //results in true
  • "Steve" matches /[S|s]te(?:ven?|phen)/ //results in true
  • /[S|s]te(?:ven?|phen)/ matches "Steve" //also results in true
Symbolic Comparison Operators

The following two operators use symbols which you may not have seen before; they return an Integer value, these are:

Examples of use are:

  • "Steve" <=> "Steve" //results in 0
  • "Steve" <~> "Steve" //results in 0

While you may think these two operators appear to do the same thing, they are in fact very different in character. The comparison operator '<=>' is intended to do a simple compare and in the case of Strings this would be lexical. For your own records and classes you can implement your own comparison mechanism; but keep to the same semantics.

The fuzzy match operator '<~>' is more sophisticated and is a sort of bridge between the comparison operator and regular expressions. Below are a couple of examples and while these examples are given as Strings you can implement your own fuzzy match on your own records and classes.

  • "Steve" <=> "steve" //results in 1
  • "Steve" <~> "Steven" //results in 2
  • "Steve" <~> "teve" //results in 1
  • "Steve" <~> "steven" //results in 3

The idea behind the fuzzy match is to try and find the best sort of match for a particular value. In this case Strings and specifically steve is the best match because just the case of the 's' had to be altered. The String built into EK9 uses a modified Levenshtein algorithm, that weights case changes, character additions and spans of common sequences slightly differently.

For your own aggregate types you could implement any sort of scoring mechanism, this is useful for sparse or incomplete data and especially when accepting user input where the validity or addresses or names could be matched against a range of known valid entries. For example; you would probably weight address fuzzy matching on different levels for country, city, street and house number. A single change of a digit in a house number has less weight than the country being "US" or "UK" for example. For some fuzzy matching you might use the proximity of keys on the keyboard being next to each other to accept 'typos' and find the actual logic word the user meant.

The mechanisms you use for your own fuzzy match should however retain the meaning of 'fuzzy'.

By creating a fuzzy match on your own developed types you will be able to use and reuse a range of generic functions and classes should they use fuzzy match in their implementation. You could imagine a sorting function that employed the fuzzy comparison to order items, if this were a generic function it could just be re-used with your types if you implemented the fuzzy match operator.

Symbolic Unary Operator

All of the operators shown so far are binary operators; the next operator is a postfix unary operator and is very useful for checking the validity of any value.

Examples of use are:

  • name1 ← "Bill"
  • name2 ← "Ted"
  • name1? //results in true
  •  
  • name3 as String
  • name3? //results in false
  •  
  • name4 ← String()
  • name4? //results in false
  •  
  • //Note the validity of the calculation is been checked here not the actual result.
  • //The use of the '( ... )' is important here. The ? applies to the result.
  •  
  • (name1 < name2)?
  • //results in true - because the calculation was valid not because Bill is Less than Ted
  • (name1 < name3)?
  • //results in false - because the calculation was invalid name is not set

The is set operator should always be present in your own implementation of records and classes as it provides a very clear and consistent way to establish whether an object contains valid data or not. It means you can create new instances of Objects without the need to provide any valid data (you may not know the values yet).

For example; lets say an Address is an aggregate of several data elements, house number, street, city and country. If you are to ask a user for such information then an Address Object needs to be created to hold it, but initially it will not be valid. Only when the user has provided the information can the Address be checked and only then is it valid (or not if the information is erroneous).

You may wish to consider using records to gather sets of data and then using those as arguments into classes, where the classes have both data and behaviour. The classes can then validate the records during consttruction. This reduces the number of fields in the class and enables the class to be either fully valid or fully invalid. This more simple approach reduces the number of arguments to a class and removes the need for 'Builders'.

Assignment Operator(s)

This next set of operators are only used in assignment - all three do exactly the same thing and really are just different syntax that is used to support developers from different backgrounds and also enable assignment in different contexts to look or feel more natural.

There is also an assign-if-unset operator (a :=? b). This operator is different to the three above. It is like a combination of an 'if statement' and an assignment.

The assignment only take place if 'a' has no memory allocation or it is unset. Only if this is the case will the variable 'a' be assigned to point to the same data as 'b'.

The basics section discusses the reasoning for the three different assignment styles; and they do tend to feel right in different contexts (this is down to personal preference).

It is important to note that really it is only a handle (pointer) that is assigned and not actual data. So using the example of the address previously.

As there are no primitives in EK9 even for Integer and Float; it is worth stating again if you alter the value that two or more variables reference; all references will see that change. This is not what you will be expecting if you are used to languages that use primitives and not Objects.

  • checkAddr as Address //not assigned to anything
  • myAddress ← Address() //assigned to a new empty address
  •  
  • myAddress.country: "UK" //assuming the Address was a record with a country field
  •  
  • checkAddr := myAddress
  • //Both myAddress and checkAddr reference the same address
  • //But the data has not been copied
  •  
  • //Change the country in either checkAddr or myAddress and value will alter
  • checkArr.country = "US"
  • //myAddress.country is now also "US"
  •  
  • anAddress as Address //not assigned to anything
  •  
  • anAddress :=? myAddress
  • //anAddress is only assigned if it was unset or had no memory allocated; as above.
  •  
  • anAddress :=? Address("US")
  • //anAddress would not have been assign above as it already has a valid Address value

The four different types of assignment operator have been shown in the example above.

For any developer with a background of C#/Java this will feel normal and natural. In effect the variables of myAddress and checkAddr are just pointers to a bit of memory that holds the actual Address data. They both point to the same bit of memory for the Address. There are times when you do want a full copy or clone of the data, for that see the next section on modification operators specifically the ':=:' (copy) and ':~:' (merge) operators.

There are also times when it is necessary to 'replace' an entry in a collection of some sort, the ':^:' (replace) operator provides a shorthand and standardised naming semantics.

The assign-if-unset operator (:=?) is new however and is a nice shorthand for simple if statements followed by assignment: a :=? b is exactly a := a ?: b, so b is not even evaluated when a is already set. No assignment yields a value, so an assignment is never an operand (ok <- x := f() does not parse - write x := f() then ok <- x?).

Modification/Mutation Operators

To be able to alter the contents of a variable (not just the references to the memory location of the variable), there are number of operators that can be used.

Important - EK9 Increment/Decrement Semantics:

This differs significantly from C/C++/Java where y = x++ returns the old value and creates an independent copy. In EK9, y ← x++ returns the new value and creates an alias to the same object.

Here are a number of examples of the above operators to illustrate their functionality.

  • aFloat ← 22.9 //Create a new floating point value
  • aFloat += 17.1 //Add the value into the variable aFloat
  • //aFloat would now have the value of 40.0
  •  
  • aFloat -= 7.5 //Subtract the value from the variable aFloat
  • //aFloat would now have the value of 32.5
  •  
  • aFloat *= 3 //Multiply aFloat by the value
  • //aFloat would now have the value of 97.5
  •  
  • aFloat /= 2.5 //Divide aFloat by the value
  • //aFloat would now have the value of 39.0
  •  
  • aFloat++ //Increment aFloat by 1.0
  • //aFloat would now have the value of 40.0
  •  
  • aFloat-- //Decrement aFloat by 1.0
  • //aFloat would now have the value of 39.0 again
  •  
  • bFloat as Float() //Create a new floating point value but not yet set
  • bFloat := aFloat //Assign the variable and update
  • aFloat++ //Increment aFloat by 1.0, but note bFloat will also change
  •  
  • cFloat as Float() //Create another new floating point value but not yet set
  • cFloat :=: aFloat //Copy the contents of the variable and update
  • aFloat++ //Increment aFloat by 1.0, but note cFloat will not change

The operators above are comparatively simple and are fairly straight forward - but check the copy ':=:' operator as that is quite different in nature.

The copy principle:

The merge rule: ':~:' never overwrites a set value; it fills whatever is unset or missing, element by element, recursing into compatible nested structures; Lists append, Sets take the union. The first value recorded wins. It means the same thing on every built-in type:

Taken means shared, not copied. What the target takes - a key it lacked, an element it held unset - is the same object the source holds, exactly as :=: shares (a shallow copy). Because merge recurses, a later merge into a structure the target took also changes the source's inner object: merge taskOne then taskTwo into an empty Dict, and the inner Dict taken from taskOne receives taskTwo's keys too. To keep every source independent, give the target its own fresh inner structure first, so each merge fills the target's structure rather than taking a source's.

To overwrite, use :=: (copy) or :^: (replace); for anything else, write your own function. Note that a stream into a value (|) is not a merge: cat [1, 2, 3] > total still sums. (A JSON object target is the one place a stream merges: see pouring into JSON.)

The merge ':~:' operator is similar in many ways to copy ':=:' operator in the fact that it alters the underlying content of the variable. In general the merge ':~:' operator is very useful when used with aggregates. Below is an example of a record; this treats fields that are not set as being suitable for replacement. There is quite a bit going on in this example, it shows a couple of other operators that have not yet need described, but is more fully featured as an example. The :=: implementation shown below is a hand-written copy - calling :=: on each field, so each field copies at its own level (a String copies its value; a List would copy its element references) - which is deeper than the shallow 'default operator :=:' (see the copy principle).

#!ek9
defines module introduction

  defines record
  
    Person
      first <- String()
      last <- String()
      dob <- Date()
      profession <- String()
      
      //is set
      operator ? as pure
        <- rtn Boolean: first? and last? and dob? and profession?
      
      //convert to string
      operator $ as pure
        <- rtn String: first + ", " + last + ", " + $dob + ", " + profession
      
      //equality
      operator == as pure
        -> other as Person
        <- rtn as Boolean: other? and first==other.first and last==other.last and dob==other.dob and profession==other.profession
      
      //Field-wise copy example - calls :=: on each field, each copying at its own level.
      //Note: 'default operator :=:' is a SHALLOW copy - each field refers to the same object as the source's.
      //Write your own :=: like this when you need a deeper or selective copy.
      operator :=:
        -> other as Person

        if other?
          first :=: other.first
          last :=: other.last
          dob :=: other.dob
          profession :=: other.profession
      
      //merge
      operator :~:
        -> other as Person

        if other?
          if not first?
            first :=: String(other.first)

          //Could be written like this with assign-if-unset.

          last :=? String(other.last)
          dob :=? Date(other.dob)
          profession :=? String(other.profession)
          
  defines program 
  
    //quick program to show how operators on Person can be used.
    PersonMerge()
      stdout <- Stdout()
      
      john <- Person()
      john.first: "John"
      john.profession: "IT"
      
      smith <- Person()
      smith.last: "Smith"
      smith.dob: 1960-01-01
      
      //So lets merge these into a new variable
      smithj <- Person()
      
      smithj :~: john
      smithj :~: smith
      
      //Alter first name
      john.first: "Jon"
      
      //Just show the values.
      stdout.println("[" + $john + "]")
      stdout.println("[" + $smith + "]")
      stdout.println("[" + $smithj + "]")
//EOF          

The output would be as follows:

[Jon, , 2020-11-03, IT]
[, Smith, 1960-01-01, ]
[John, Smith, 1960-01-01, IT]

The example above is quite long; but hopefully gives you a much better idea of the operators available and how powerful they can be. Note that because the merge was done before the first name was altered to "Jon", the merged record holds its own copy of that value.

Boolean (Logical) and Bitwise Operators

This section focuses on a number of operators that are used by two very different types, but the concept and meaning of the operator is broadly the same. Other languages model these operators in different ways. In EK9 the same syntax is used in both contexts.

Boolean (Logical) Operators

The first set of operators are the Logical operators that are used with Boolean values.

Most of the above operators will be familiar to developers, the '~' and 'xor' may not. The '~' is the same as the 'not' operator. But not that many languages support the 'xor' operator (though it can be quickly implemented with combinations of 'and'/'or').

  • //Simple boolean values
  • a ← true
  • b ← true
  • c ← false
  •  
  • //Each of the operators
  • //The and/not operator
  • result ← a and b //result would be true
  • result ← a and c //result would be false
  • result ← a and not c //result would be true
  • result ← a and ~c //result would be true
  •  
  • //The or/not operator
  • result ← a or b //result would be true
  • result ← a or c //result would be true
  • result ← not a or c //result would be false
  • result ← ~a or c //result would be false
  •  
  • //The xor/not operator
  • result ← a xor b //result would be false
  • result ← a xor not b //result would be true
  • result ← a xor c //result would be true
  • result ← not a xor c //result would be false
  • result ← ~a xor c //result would be false
Bitwise Operators

The second set of operators are the bitwise operators that are used with bit(s) values. The look and syntax is the same as the logic operators but there are a couple of additional operators (shift left/right). But critically these work with sets of bits (these are not ints or longs - but are an ordered collection of bits).

Note that the least significant bit is on the right and the most significant bit is on the left as you would expect.

  • //Simple bits values
  • a ← 0b010011
  • b ← 0b101010
  • c ← 0B01010011
  •  
  • //Each of the operators
  • //The not operator
  • result ← not a //result would be 0b101100
  •  
  • //The and operator
  • result ← a and b //result would be 0b000010
  •  
  • //The or operator
  • result ← a or b //result would be 0b111011
  •  
  • //The xor operator
  • result ← a xor b //result would be 0b111001
  •  
  • //The << operator
  • result ← c << 1 //result would be 0b010100110
  • result ← c << 2 //result would be 0b0101001100
  •  
  • //The >> operator
  • result ← c >> 2 //result would be 0b00010100

The most important point about the bitwise operators is that they operate on sets of bits; these are not longs or ints or any other primitive type. They are only sets of bits that can grow (as you see in the shift left operator). There are more details on the set of bits type in built-in types and Standard Types. For example; it is possible to join sets of bits and use these with pipeline processing to check specific values in a sets of bits; but you cannot consider them a primitive type nor an array of bits (there are no arrays).

Ternary style Operators

There are a number of ternary operators that have a shorthand, some of which are new. See the example at the end of the section on why these were introduced and what the benefits of using them are.

The main ternary operator is shown below first as it will be familiar to many developers:

  • //Setup values
  • bird1 as String
  • bird2 ← "Duck"
  •  
  • birdA ← bird1? ← bird1 : bird2
  • //birdA would have the value of "Duck" because 'bird1' is not set
  • //Note this could have been written as
  • birdA := bird1? ← bird1 else bird2
  • //Maybe this is less terse but more readable and obvious.
  •  
  • bird1 := String()
  • birdA := bird1? ← bird1 : bird2
  • //birdA would still have the value of "Duck" because 'bird1' does not have a meaningful value
  •  
  • bird1 := "Goose"
  • birdB ← bird1? ← bird1 : bird2
  • //birdB would now have the value of "Goose" because bird1 is set

This is a standard ternary operation; instead of 'bird1?' any Boolean expression can be used. As you can see using 'else' rather than the ':' makes the ternary operator a little more obvious and readable.

Coalescing Operators

There are a number of coalescing operators, these work on variables that are not set. These are:

The coalescing operator (Elvis) '?:' is a bit special (as you would expect). It does both a memory allocation check and an is set check. This is the main difference to the '??' operator which only does a memory allocation check.

Every coalescing operator (and the ternary) returns one of its operands, so the operands must share a type: the same type or a subtype, one operand that promotes (#^) to the other's type (count ?: 2.5 is a Float, and only the operand chosen is promoted), or their nearest common super class or trait. Operands that share nothing but Any are a compile error at the operator (E50030) - never an invented Any.

The coalescing operators ?:, ?? and <? <=? >? >=? share one level that binds tighter than arithmetic, comparison and logic, and other languages disagree about where they sit, so EK9 never lets one of them mix with a different binary operator without parentheses (E08269): write (a + b) ?: c or a + (b ?: c), and (a ?? b) + c, never a ?? b + c. A chain of the same operator, a ?: b ?: c or a ?? b ?? c, needs none.

A unary operator binds tighter than any binary operator, the coalescing ones included: -x ?: 5 is (-x) ?: 5, ~ready ?: true is (~ready) ?: true, $count ?: "none" is ($count) ?: "none" and flag ?: text is empty is flag ?: (text is empty). An unset operand of - or ~ gives an unset result, so the coalescing operator then supplies its fallback. There are two exceptions, both the usual convention: unary minus binds below ^ (-x ^ 2 is -(x ^ 2), and so is -2 ^ 2: a literal is no exception, so it is -4 - write (-2) ^ 2 to square the negative number), and the word not binds below the comparisons (not age == limit is not (age == limit), not name in names is not (name in names) and not ready ?: true is not (ready ?: true)). Every postfix operator binds above unary minus, so -x is empty is -(x is empty).

Read from the right, the word not would not reach past an operator before it, so ready == not done is a compile error (E08273), as it is in Python: write ready == (not done), or not (ready == done) if that is meant. It applies to every binary operator above not; ready and not done, ready or not done and ready xor not done have one reading and need no parentheses. Every prefix operator binds tighter than the word not as well, so $ not done and #? not done are errors too: write $ (not done). In one sentence: the word not is never an operand of another operator unless parenthesised, except and, xor and or. ~ is the tight negation: ready == ~done is ready == (~done).

Word not, ~ and unary - are the same negation, so a negation applied to a negation cancels out: not not done, ~~done, not ~done, - -amount, -not done and, parenthesised, not (not done), ~(~done) and -(-amount) are all compile errors (E08274). Write the operand, or a single negation if one was meant - EK9 cannot tell which, so it does not choose for you. Subtraction is not a negation: -(first - second) and first - -second are legal.

Operator precedence

EK9 follows the convention of C, Java and Python. From the tightest binding to the loosest:

Level Operators Example and its reading
Postfix and prefix x++ x-- x! x?, $ $$ #^ #< #> #? length abs sqrt ~, is empty $count ?: "none" is ($count) ?: "none"
Power (right-associative) ^ 2 ^ 3 ^ 2 is 2 ^ (3 ^ 2), 512
Unary minus -x -x ^ 2 is -(x ^ 2), and -2 ^ 2 is -(2 ^ 2), -4; -x * 2 is (-x) * 2
Coalescing (right-associative) ?: ?? <? <=? >? >=? a ?: b ?: c is a ?: (b ?: c); a mix with any other operator, ?? included, needs parentheses (E08269)
Multiplicative * / mod rem a + b * c is a + (b * c)
Additive + - a - b + c is (a - b) + c
Shift << >> bits << n + 1 is bits << (n + 1)
Comparison <=> <~> a <=> b
Relational and membership < <= > >= matches contains in, in a ... b name in names == expected is (name in names) == expected; valid and age in 18 ... 65 is valid and (age in 18 ... 65)
Equality == <> != a < b == c is (a < b) == c
Logical not not not age == limit is not (age == limit); not a and b is (not a) and b; a == not b needs parentheses (E08273), a and not b does not
Logical and, then xor, then or and, xor, or a or b and c is a or (b and c)
Ternary (right-associative) <- ... else ok and ready <- first else second is (ok and ready) <- first else second

While not a ternary operator; the assign-if-unset (:=?) operator 'feels' related: it is the assignment form of the coalescing operator (Elvis) - a :=? b is a := a ?: b. The right-hand side of the assignment is only evaluated if the left-hand side is unset (or has not been allocated storage). Either way it leaves an object in its target - present, maybe unset, never absent - so it initialises a variable declared with no value. To do the opposite - take a new value only when it is set, and otherwise keep the value you have - write x := f() ?: x. The '??' operator will not do that: an unset value still has memory allocated, so '??' passes it straight through. A plain assignment will not either: x := f() always stores even an unset result.

  • //Setup values
  • bird1 as String
  •  
  • bird1 :=? "Goose"
  • //bird1 would have the value of "Goose" because 'bird1' is not set
  •  
  • //In contrast.
  • bird1 ← "Duck"
  •  
  • bird1 :=? "Goose"
  • //bird1 would have the value of "Duck" because 'bird1' was originally set

You maybe thinking why not just use the normal if statements and comparison operators, why add these as well. The normal comparison operators will return an unset value if either of the items being compared are unset. A value with compared to another unset value will result in a meaningless result. These operators accept that either (or both) of the values can be unset.

  • //Initialise values
  • bird1 as String
  • bird2 ← "Duck"
  • birdA ← bird1 ?? bird2
  • birdA := bird1 <? bird2
  • birdA := bird1 <=? bird2
  • birdA := bird1 >? bird2
  • birdA := bird1 >=? bird2
  • //In all above examples birdA would have the value of "Duck" because 'bird1' is not set
  •  
  • //As you can see bird1 is not less than, equal to or greater than bird2 because bird1 not set.
  •  
  • birdB ← bird1 ?: bird2
  • //birdB would also have the value of "Duck" because 'bird1' is not set
  •  
  • bird1 := String()
  • birdA := bird1 ?? bird2
  • //birdA would now be un set and not have a meaningful value because it has taken same value as 'bird1'
  • //But importantly bird1 does have memory allocated.
  •  
  • birdB := bird1 ?: bird2
  • //birdB would have the value of "Duck" because 'bird1' does not have a meaningful value but does have memory allocated.
  • //this is the main difference between ?? and ?:
  • //By the same logic birdB would have the value of "Duck" when the <?, <=?, >?, >=? operators are used.
  •  
  • bird1 := "Goose"
  • birdC ← bird1 ?? bird2
  • //birdC would now have the value of "Goose" because bird1 is set
  • birdC := bird1 ?: bird2
  • //birdC would also have the value of "Goose" because bird1 is set
  • birdC := bird1 <? bird2
  • //birdC would have the value of "Duck"
  • birdC := bird1 <=? bird2
  • //birdC would have the value of "Duck"
  • birdC := bird1 >? bird2
  • //birdC would have the value of "Goose"
  • birdC := bird1 >=? bird2
  • //birdC would have the value of "Goose"

As you can see there is a small but important difference between '??' and '?:'. These operators have been introduced to make the processing of variables that have or have not been given a meaningful value easier. They simplify comparisons where some values may not have been set.

The operators '<?', '<=?', '>?' and '>=?' follow the same logic as the coalescing operator (Elvis) '?:' (as does the assign-if-unset ':=?' operator), but they always evaluate both operands and return one of them. They work with variables that have no memory allocated (null) or do have memory allocated but are unset, or do have a meaningful value.

Assign-if-unset Operator

So in short; if you want to make an assignment by comparing two variables and take into account that they may not have a meaningful value use '<?', '<=?', '>?' and '>=?' in preference to '<', '<=', '>' and '>=', unless you actually need to retain the fact that the comparison was a meaningless one. If you want only want to assign a variable if it currently is unassigned then use the assign-if-unset operator (':=?').

One of the most important concepts in EK9 is that a variable can exist but not hold a meaningful value. This is really important as most developers would expect the comparison of two values to result in either:

But there is a forth state where either one or both of the values is not set. With standard comparison operators the test becomes meaningless and not valid. These operators support the idea that you may wish to compare values and account for the fact that one or both might not be set. If both do have meaning then the normal comparison operator logic is applied.

Comparison Coalescing Semantics

The comparison coalescing operators follow a priority cascade to determine the result:

  1. If left operand is null or unset → return right operand
  2. Else if right operand is null or unset → return left operand
  3. Else both are valid → perform comparison and return appropriate value
  4. Default → return right operand

This ensures that:

Truth Table for <? (Lesser Value)
x state y state Rule Applied Result
null null x invalid y
null unset x invalid y
null set x invalid y (use valid value)
unset null x invalid y
unset unset x invalid y
unset set x invalid y (use valid value)
set null y invalid x (use valid value)
set unset y invalid x (use valid value)
set set compare x if x<y, else y

The operators >?, <=?, and >=? follow the same validity checking logic. Only the final comparison step differs when both operands are valid.

Note: The compiler detects coalescing expressions where both operands are literal values as tautological (E08094), since the result is predetermined at compile time. So is a literal on the left of ?:: it is always set, so the right would never be used.

These new operators may seem like just yet another thing to learn, but you could just write the following:

  • //Values to work with
  • bird1 as String
  • bird2 ← "Duck"
  •  
  • //Without using new operators
  • birdA ← String()
  • if not bird1?
  •   birdA := bird2
  • else if not bird2?
  •   birdA := bird1
  • else
  •   birdA := bird1 < bird2 ← bird1 else bird2
  •  
  • //With the new operator
  • birdA ← bird1 <? bird2
  •  
  • //With the new operator and function calls
  • birdA ← functionToGetBird1() <? functionToGetBird2()
  • //Without the new operators, you would want to avoid multiple calls!

So these new operators are more than just a null safe version of the normal comparators, they are a mix of the coalescing operator (Elvis) and comparators/assignment operators. They really reduce the amount of null and if/else code. They also dovetail in with type inference, note how in the example above birdA is declared, type inferred and assigned all in one go.

Finally (and this section has been long) imagine if the bird1 and bird2 had not actually been variables, but instead had been calls to functions or methods. You would now be forced to use additional variables to capture the results of those calls. With the new coalescing operators that is not necessary.

Mathematical

While these sets of operators are under the section called 'Mathematical' operators; addition '+' and subtraction '-' can and should be used with other types, records and classes. A good example of this would be a collection like a List using '+', '+=', '-', '-=' make sense as it is rational, logical and is semantically correct.

Examples of these mathematical operators are shown below.

  • //Initialise values
  • a ← 21.9
  • b ← 13.6
  • c ← Float()
  • d ← Float()
  •  
  • //Addition examples
  • e ← a + b
  • //'e' would have the value of 35.5
  • e := b + a
  • //'e' would again have the value of 35.5
  •  
  • //But in these following cases 'c' is not set and so the result is also not set
  • e := a + c
  • e := c + a
  •  
  • //Also in these following cases both 'c' and 'd' are not set and so the result not set
  • e := c + d
  • e := d + c
  •  
  • //Subtraction examples
  • e := a - b //'e' = 8.299999999999999
  • e := b - a //'e' = -8.299999999999999
  • e := a - c //'e' = not set
  • e := c - a //'e' = not set
  • e := c - d //'e' = not set
  • e := d - c //'e' = not set
  •  
  • //Multiplication examples
  • e := a * b //'e' = 297.84
  • e := b * a //'e' = 297.84
  • //all other combinations involving 'c' or 'd' result in 'e' being not set
  •  
  • //Division examples
  • e := a / b //'e' = 1.6102941176470587
  • e := b / a //'e' = 0.6210045662100457
  • //all other combinations involving 'c' or 'd' result in 'e' being not set
  •  
  • //Factorial examples
  • alpha ← 5
  • e := alpha! //'e' = 120.0
  •  
  • //Power examples
  • beta ← 5
  • e := beta ^ 2 //'e' = 25.0
  • e := beta ^ 3 //'e' = 125.0
  • e := beta ^ 4.8 //'e' = 2264.936448992798
  •  
  • //Modulus and remainder examples
  • charlie ← -21
  • f ← charlie mod 4 //'f' = 3 because -21 + 4 x 6 is 3
  • f := charlie rem 4 //'f' = -1 because -21 / 4 gives -5 with a remainder of -1
  • //For positive values the results are the same for modulus and remainder
  • delta ← 21
  • f := delta mod 4 //'f' = 1
  • f := charlie rem 4 //'f' = 1
  •  
  • //Absolute - abs example
  • f := abs charlie //'f' = 21
  • f := abs delta //'f' = 21
  • echo ← -23.88
  • e := abs echo //'e' = 23.88
  •  
  • //Square root - sqrt example
  • e := sqrt delta //'e' = 4.58257569495584
  • e := sqrt abs echo //'e' = 4.886716689148247
  • //Note the combination of sqrt and abs.
  •  
  • e := sqrt echo //'e' = not set cannot sqrt a negative number
  • EK9 does not have imaginary numbers so sqrt of a negative number is not valid
Functional

EK9 includes a number of operators that are not found in most other languages. Again these are aimed at encouraging standard naming and also readability. They also model some standard type concepts, like conversion to a String/Hash code and promotion from one type to another (i.e Integer to Float). Another concept that is used quite widely is closing of 'things', such as connections, files, collections or anything where explicit completion of use needs to be de-marked in some way. As you will see in the section on Exceptions and Error Handling the use of close with a try block means that resources that have to be managed (opened/closed) can be dealt with in quite a terse and simple manner that is consistent.

EK9 also includes an operator to convert built-in type and records/classes to JSON. In the case of records the implementation can be defaulted in most cases. But there are times when it is necessary for you to provide your own implementation.

You may have noticed there are some operators that use some additional wording like is or of these are just a bit of sugar you can omit them if you wish; but sometimes it makes the syntax more readable.

  • //Initialise values
  • anInteger ← 106
  • aList ← ["V1", "V2", "V3"] //Remember this is a List not an Array
  • aString ← "A-to-Z"
  • money ← 350.50#USD
  •  
  • //Examples of use with a List of Strings
  • isEmpty ← aList is empty //isEmpty will have value of false, because the list is not empty
  • isNotEmpty ← aList is not empty //isNotEmpty will have value of true, because the list is not empty
  • //The above could have been written as
  • isNotEmpty ← not aList empty //But this reads like Yoda might have said it.
  •  
  • //Length example
  • listLength ← length of aList //listLength will be 3
  • //The above could have been written as
  • listLength ← length aList //The 'of' is just to add readability
  •  
  • //Convert List to a String and get its length
  • listAsString ← $ aList //listAsString will be "[V1, V2, V3]"
  • lengthOfListAsString ← $ length of $ aList
  • //lengthOfListAsString will be 12
  •  
  • //Accessing specific items in the List safely with getOrDefault
  • v1 ← aList.getOrDefault(0, "") //v1 will be "V1"
  • v3 ← aList.getOrDefault(2, "") //v3 will be "V3"
  •  
  • //Access by index with a safe default for out-of-range indices
  • maybeAString ← aList.getOrDefault(5, "") //maybeAString will be "" - the default, as there are only 3 items
  • //getOrDefault always returns a value: either the element at the index, or the provided default
  • //No exceptions, no unset results - safe by design
  •  
  • //But the pipeline processing is more powerful
  • v2 ← cat aList | skip 1 | head 1 | collect as String //v2 will be "V2"
  • //Pipeline processing is also safe by avoiding indexing
  • nonExistent ← cat aList | skip 4 | head 1 | collect as String
  • //nonExistent will be not set as there are only 3 Strings in the list
  •  
  • //Same syntax can be used with Strings (or your own records/classes if you provide operators)
  • isEmpty := aString is empty //isEmpty will have value of false, because the string has a value of "A-to-Z"
  • firstChar ← #< aString //firstChar will be 'A'
  • lastChar ← #> aString //lastChar will be 'Z'
  • //The important point here is the same syntax works with different types like String, Integer, Money
  • //Further example shown using Money
  • amount ← #< money //amount will be 350.5
  • currency ← #> money //currency will be USD
  •  
  • //The following examples outline hash code and promotion of type
  • aFloat ← #^ anInteger //aFloat will be 106.0 (i.e a floating point number)
  • //With your own records and classes you can provide your own promotions
  •  
  • hashCode ← #? anInteger //hashCode will be 106
  • hashCode := #? aFloat //hashCode will be 1079672832
Pipeline operator

Finally there is one operator that is only used in conjunction with Streams and Pipeline processing and that is the pipe '|' operator. It is an operator that is provided on the built-in types where appropriate (List/Integer/Float for example) to allow for the accumulation of values. But note it can also be overridden in your own Records and Classes.

Its semantics are similar to the '+=' operator but with one very significant difference. Whereas the '+=' operator typically does not allow an addition to the value when it itself is unset the '|' operator does. This means that value can transition from being unset to having an initial value when the '|' operator is first called. Subsequent calls to the '|' operator should use the value being passed in to accumulate or replace the current value (depending on the type).

See Integer type in a pipeline for an example of this.

Pouring into JSON follows the target's shape: an unset or array target collects, an object target merges each streamed object in; anything else - a non-object into an object, or anything into a single value - is invalid, leaves the target unset, and later items do not revive it: a mismatch anywhere in the stream is still visible at the end, until the target is assigned again (:=:, :~: or clear()). An array target takes each item as one element (an array poured in is not spread), an object target merges by the merge rule (the first value recorded wins), and a JSON null item is unset, so it is dropped. See JSON.

Defaults

You may be used to your programming language or tool chain (like Java Lombok) providing many of these operators out of the box; this is so you don't have to implement them yourself.

EK9 does provide this functionality; but it does so in a layered and controlled manner that applies to records and classes only. See the sections on Records and Classes for more details.

Simple operators like '$', '#?', '?' can be generated by EK9. It uses the fields/properties held in the record/class and those of any super record/class. Those fields must also have the operators present (else the compiler will issue an error).

For records; EK9 can also automatically generate the JSON ($$) operator code by default in most cases.

The converse is not always true however, EK9 cannot always create the code to take JSON and convert back into records. If the records contain fields/properties that are abstract in nature then it falls to the developer to write that code to reconstitute a record from a JSON representation.

The Comparison/Equality operators like '==', '<>', '<=>', '<', '<=', '>', >=' can be generated by EK9 by looking at each of the fields. This is done primarily through the comparison ('<=>') operator. If the record/class has a super; this is called first; and only if results in 0 for comparison are the fields in the current instance evaluated.

The generated operators implement tri-state comparison semantics for each field:

This approach ensures that comparisons between objects with different field initialization states correctly propagate uncertainty, while objects with matching unset fields can still be meaningfully compared. To alter this behaviour, just override the '<=>' operator and provide the implementation you would prefer.

The idea of 'default operator' is very useful and enables you to provide all the operators in a simple and straightforward manner. But it also enables you to provide a finite list if you wish and provide your own implementations as and when you see fit.

Operators Supporting Default

The following operators can be declared with the default keyword on records and classes:

Operator Method Return Type Description
<=>_cmpIntegerThree-way comparison (returns -1, 0, or 1)
==_eqBooleanEquality check
<>_neqBooleanNot equals (delegates to ==)
<_ltBooleanLess than (derived from <=>)
<=_lteBooleanLess than or equal (derived from <=>)
>_gtBooleanGreater than (derived from <=>)
>=_gteBooleanGreater than or equal (derived from <=>)
?_isSetBooleanCheck that ALL fields are set (and super.?)
$_stringString?String representation, Type(Super(...), field=value); UNSET if any field (or the super) is unset, or if there is nothing to render - no properties anywhere in the type and its supers
$$_jsonJSONJSON object of the properties, inherited ones included (an unset property is JSON null); UNSET only if there is nothing to render
#?_hashcodeInteger?Hash code (UNSET when the object is unset under ?)

Note: The comparison operators (<, <=, >, >=, ==, <>) require the <=> operator to be present (either defaulted or explicitly defined) on the same type and any super types.

Default Operator Semantics

The default operator ? returns true only if EVERY field is set - as if the whole inheritance chain were one object. A partly set object is unset. For types with inheritance, the super's ? is checked first: if it returns false, so does the derived class.

Note on Any: Since all EK9 types implicitly extend Any, and Any._isSet() always returns false, the default ? operator does not call super._isSet() when the immediate super is Any. This means a class with no explicit parent only checks its own fields. When you explicitly extend another class, that parent's ? is checked first.

Types with no fields: a class, component, service or dynamic class with no fields at all (counting inherited ones) is set whenever it is present - the compiler gives it a ? that returns true, so you do not write default operator ?. A ? the type writes, or inherits from a super or trait, is never replaced, and a sub-type that adds fields still defines its own. Because such a value is always set, checking a fresh one (s <- Stateless() then if s?) is reported as always true (E08088). Records are different: a record exists only to carry data, so a concrete record with no fields, counting inherited ones, is a compile error (E07237); only an abstract record, the root of a record hierarchy, may have none.

What the generated #?, $ and $$ check first. The generated #? asks the object's own ? (yours, if you wrote one): an object that is unset under ? has no hash, so #? is UNSET. The generated $ and $$ never consult ? - they render whatever data is present, so a custom ? for domain validation does not change them. $ is UNSET when any field it renders (or the super's rendering) is unset; $$ renders an unset property as JSON null.

Whether there is anything to render is decided by the compiler, from the type itself - not at run time. A type with no properties of its own and no super that contributes any (no properties anywhere in its chain) has nothing to render: its generated $ and $$ simply return UNSET. A sub-type with no properties of its own below a super that has some renders the super's, nested - for example Named(Person(name=Ada)) and {"name":"Ada"}. A super whose $ or $$ is abstract contributes nothing: there is nothing there to call.

An abstract $, $$ or #? only where there are no fields. Because a generated operator below an abstract one covers only the sub-type's own fields, an abstract $, $$ or #? on a type that HAS fields (its own or inherited ones) is a compile error (E07185), reported at the abstract operator: those fields would silently be left out. Give it a body or default operator $ where the data is (a hand-written body when the fields are not to be shown). A field-less abstract root of a hierarchy, or a trait, may keep the operator abstract - there is nothing to lose. (== and <=> are never abstract, E07183; ? always overrides Any's, so it cannot be abstract either.)

The nearest operator above, and a sub-type that adds fields. A generated operator folds in the nearest implementation above its type - the super's, or one further up the chain when the types in between declare none - so a type with no fields in the middle of a hierarchy needs no operators of its own and adds no layer: Leaf(Base(id=1), name=leaf). A type that adds fields must declare its own $, $$ and :=: wherever it would otherwise inherit one (E07238), as it must declare ? (E07235): the inherited operator was written for the super's fields and cannot know the new ones. Sameness is different - <=> or ==, with #? concrete beside it (E07184), is declared once, at the root, and every sub-type inherits it.

Summary

As you can see there are quite a few operators, most should be fairly obvious; especially the logical and mathematical operators. The others are there to enable you to deal with common operations such as type promotion, hashing and string conversion in a terse/succinct manner.

The other main operators are employed to deal with variables which may or may not be set. As mentioned earlier a variable without meaningful data is an important concept and this is why the additional operators have been created.

These null safe ternary, coalescing and assign-if-unset operators may take some getting used to. Just remember if you are checking if a value is being set with an 'if statement'; there maybe a built-in operator that can help reduce your code.

Next Steps

There is a bit more detail on operators in the section on built-in-types. That should be the section you review next, as structure covered the overall layout and constructs and this section covered what type of operators are available.