Should a programming language allow to overload operators? Should it allow to use symbols for method-names and variables? And is it okay if the same piece of code behaves differently in different places, depending on its surroundings/context?
Ask those questions and enjoy a never-ending flame war among software developers.
Contexts outside of programming #
To get right to the point: I believe it is a good thing if a language allows to have (explicit) contexts which can change semantics of code and give meaning to symbols and even "overload" them. My reasoning is that we, as humans, are used to act differently depending on our context all the time. And we can take advatange of this built-in optimization of our brains to increase productivity.
We already do that outside of programming. Uniforms are an example. They don't just allow everyone to recognize someone's function or position; they also remind the wearer of the context they are currently operating in. A judge is reminded that they are now supposed to act as a judge and not just as themselves, reinforcing fair decision-making.
Something that maybe hits closer to home are pyjamas. At least for me, when wearing them I get into a very different mood than when wearing stiff and formal clothes. It makes it easier for me to fall asleep as well. On the other hand, working in pyjama feels just wrong. Music and scents are yet another feature that can have similar impacts. And the list goes on.
Contexts in programming #
So how is this relevant in programming? Well, when programming we do two things. We express our intent/thoughts as code in a way that 1.) the computer understands it but also 2.) ourselves and others can understand it later. The more concise and precise we describe the real thing the easier it is to understand and change the code.
In the real world there are many symbols have different meaning depending on the context.
For instance, what does 5 ± 2
mean? Without context that's hard to tell. Does it mean 3 or 7
? Does it mean 5 with a tolerance of 2
or does it maybe mean A mean of 5 with a deviation of 2
? The plus-minus sign has many meanings, depending on the context.
The same is true for many other domains, not just mathematical notations, but let's stick to that for now as an easy to understand example.
Of course, as developers we don't necessarily have to follow all conventions and notations of the real world. And sometimes we deliberately choose not to. But being able to stay very close to business-lingo in our code can definitely be very helpful. It reduces friction when encoding business requirements into code and can make code more concise in general.
Usage in practice #
How does it look like if we try to make our code be context dependant?
Most programming languages actually don't support this or at least make it very hard and unergonomic. But there are some languages that are great for it. Scala is such a language. It embraces contexts and makes them ergonomic to use while making it hard to shoot yourself into the foot.
So for the following real-life examples, we're going to use Scala.
Using ± to describe choices #
Let's say we are dealing with a business domain where we often have to deal with choices and our business experts use the ± in all their technical documents.
How can we make our code match the domain?
First we define what a choice is
enum Choice:
case Numbers(lower: Int, upper: Int)
case Choices(lower: Choice, upper: Choice)
A choice can be 2 numbers (e.g. 5 ± 2
would be 3
for lower or 7
for upper).
But if we have a choice and work with it, we might end up with a choice of choices, e.g. ((5 ± 2) ± 1)
would be either 3 ± 1
or 7 ± 1
.
We are working with regular integer values (Int
) from the standard library. In our code we want to write 5 ± 2
but the type Int
is already defined. Luckily Scala supports statically typed extension methods:
extension (number: Int)
def ±(diff: Int): Choice.Numbers = Choice.Numbers(
lower = number - diff,
upper = number + diff
)
This already let's us to write code that is valid and works as expected:
print(5 ± 2) // prints: Numbers(3,7)
To make it a bit more interesting we also want to work on choices of choices, i.e. calling ±
not only on numbers but on choices themselves. To allow that, we add another method:
extension (choice: Choice)
def ±(diff: Int): Choice.Choices = choice match
case Choice.Numbers(lower, upper) => Choice.Choices(
lower = lower ± diff,
upper = upper ± diff
)
case Choice.Choices(lower, upper) => Choice.Choices(
lower = lower ± diff,
upper = upper ± diff
)
We could define this code top-level and just start using it in our project - but since we want to create an explicit business context, we move the whole logic into its own "context" object (or module):
object choiceContext {
extension (number: Int)
...
extension (choice: Choice)
...
}
The encapsulating object really just acts as a namespace that needs to be explicitly imported. If we just try to use the new syntax in a project then it will fail by default:
val choice = 5 ± 2
print(choice)
Fails with:
value ± is not a member of Int
val choice = 5 ± 2
This is good because without further information what does it mean when we use ±
?
We should be clear about what we mean when we use ±
and we can do so by telling the compiler (and thus other developers) that we are working within the "context of choice". Then it will compile and behave as expected:
import choiceContext._ // Make it explicit that we want to be working in the choice context
val choice = 5 ± 2
println(choice) // prints: Numbers(3,7)
val moreChoice = choice ± 1
println(moreChoice) // prints: Choices(Numbers(2,4),Numbers(6,8))
We can now work with a nice and concise mathematical notation and extend it with more functionality to match the lingo of our domain experts. Who knows, the code might get so close to what those experts are used that they can understand the source code or even write it themselves without the help of a developer. (Let me dream of it at least...)
Using ± in another context #
It probably does not happen very often, but what if we require to use ±
in a second context, like the context of tolerance like in physical engineering?
We might want to write code like val tolerance = (5 ± 2)
and then if( 42.exceeds(tolerance) )
or similar, because that's what our domain exports do.
We can do that in the same way as before:
case class Tolerance(from: Int, to: Int)
object toleranceContext {
extension (number: Int)
def ±(diff: Int): Tolerance = Tolerance(
from = number - diff,
to = number + diff
)
}
import toleranceContext._
print(5 ± 2) // prints: Tolerance(3, 7)
The first question that should pop up in your head is: can we use two contexts at the same time?
And sure, we can use both contexts or even more of them at the same time!
But then what happens if we mess up? In this example we use ±
in both contexts - so what will happen if we call ±
on a number? Will the result mauybe depend on the order of imports?
Fortunately it won't (this would be insane anyways). The compiler will refuse to compile:
import choiceContext._
import toleranceContext._
val result = 5 ± 2
Fails with:
value ± is not a member of Int.
An extension method was tried, but could not be fully constructed:
toleranceContext.±(5) failed with
Reference to ± is ambiguous,
it is both imported by import choiceContext._
and imported subsequently by import toleranceContext._
val result = 5 ± 2
Great! At least it's hard to make any mistakes by accidentally screwing up context imports.
So what do we do if we have two contexts that just share one common symbol that we want to use at some point? Luckily we are not forced to shenanigans like splitting up our code and then using just a single context for a specific part.
Instead, we can tell the compiler explicitly to use the version of ±
that we are interested in:
val result = choiceContext.±(5)(2) // a bit ugly but works
There are other ways to resolve this situation and make it more ergonomic, but let's not go down the rabbit hole here.
A word about naming #
So far I think this looks all great, and I hope you agree me. But you might still be sceptical for a different reason, and rightfully so!
Using symbols as names and identifiers in code is not allowed or discouraged in many programming languages and that has a history. And while I believe it is good to be able to use them, one should do so with great care.
With great power comes great responsibility. When using a symbol that is not widely used/understood then my strong recommendation is to always create a method with a readable name and make the symbolic name just an alias.
For instance, in the case of ±
I would write the following code in real life:
extension (number: Int)
// Alias for plusminus
def ±(diff: Int): Choice.Numbers = plusminus(diff)
def plusminus(diff: Int): Choice.Numbers = Choice.Numbers(
lower = number - diff,
upper = number + diff
)
This allows to use both 5 ± 2
as well as 5.plusminus(2)
and allows to easily find a name that can be searched for or pronounced more easily.
Conclusion #
It will certainly stay a subjective matter, but if a programming language offers good support and is statically typed then it becomes possible to make code much more concise and readable while keeping it safe from accidental mistakes.
In such a case I find there is very little reason to restrict the usage of symbols and context-dependant code behaviour.