A general mechanism to implement overloaded symbolic operators
Many different types support, in some way, the concept of arithmetic
operations: they can be added, subtracted, multiplied and divided. Or perhaps
just some of these. We want to use the familiar binary arithmetic operators
(+
, -
, *
and /
) to work with these types, so that is the first thing
Symbolism provides, though typeclass definitions for the four main arithmetic
operations, plus negation and square and cube root methods.
- uses typeclasses as a modular way to implement symbolic operators
- avoids overloading errors when mixing different projects which define symbolic extension methods
- provides typeclasses for the arithmetic operators,
+
,-
,*
and/
- provides a single inequality typeclass for the comparison operators,
<
,<=
,>
and>=
Symbolism has not yet been published. The medium-term plan is to build it with Fury and to publish it as a source build on Vent. This will enable ordinary users to write and build software which depends on Symbolism.
These work out-of-the-box: if your type provides an appropriate arithmetic typeclass instance, you can use its arithmetic operator. The typeclasses are:
Addable
Subtractable
Multiplicable
Divisible
Negatable
, andRootable
But these typeclasses provide another important facility: they allow us to abstract over the arithmetic operations. That means it's possible to write code against these typeclasses which will work on any type which implements them.
For example, Mosquito is a library
for working with Euclidean vectors (called Euclidean
) and matrices of
arbitrary types, such as Double
s or even Exception
s. With Mosquito, you can
add two vectors if, and only if, you can add their elements. So you can add two
vectors of Double
(as long as they have the same dimension), but you can't
add two vectors of Exception
because Exception
s have no Addable
instance.
We can go further, and calculate the scalar (dot) product if we also have a
Multiplicable
typeclass, and the vector (cross) product if we have a
Subtractable
typeclass too.
Similarly, Baroque provides an implementation of complex numbers, which are generic in the common type used for their real and imaginary parts. Operations on Baroque complex numbers are provided if their generic types support the necessary operations.
Quantitative offers representations of physical quantities, such as 10Nm (10 Newton Metres), and supports all arithmetic operations on these values through Symbolism typeclass instances. The types of the operands do not need to be the same, and the units of the result type can be computed from the input units. For example, 10Nm / 5s would produce a value of 2Nm/s. This is no problem at all for Symbolism. Nor is it problematic for Mosquito which would be happy to calculate the cross product of two vectors of different units, and compute the resultant units.
The power of this abstraction is demonstrated by Mosquito, Baroque and Quantitative all having a dependency on Symbolism, but no dependencies on each other.
Symbolism's binary arithmetic typeclasses are determined by three types
representing the left operand, the right operand and the result type. Often
these will all be the same type, but it's possible for them to differ, for example
when multiplying a String
by an Int
, with a String
as the result.
The type of each typeclass can be specified is the form,
LeftOperand is Operation[RightOperand] into Result
. Hence, we have:
Augend is Addable by Addend into Result
Minuend is Subtractable by Subtrahend into Result
Multiplicand is Multiplicable by Multiplier into Result
Dividend is Divisible by Divisor into Result
and have the methods,add
,subtract
,multiply
anddivide
defined upon them.
Implementing a given
instance is as simple as defining. For example, imagine
adding a length of time (a Duration
) to a point in time (an Instant
). We
could write,
given Instant is Addable by Duration into Instant:
def add(left: Instant, right: Duration): Instant = left.plus(right)
or even,
given Instant is Addable by Duration into Instant = _.plus(_)
taking advantage of the fact that Addable
is a SAM type.
If we were defining our own Instant
and Duration
types, we might write this:
object Duration:
given Duration is Addable by Duration into Duration = _.plus(_)
given Duration is Subtractable by Duration into Duration = _.minus(_)
given Duration is Multiplicable by Double into Duration = _.times(_)
given Duration is Divisible by Double into Dulation = _.divide(_)
object Instant:
given Instant is Addable by Duration into Instant = _.plus(_)
given Instant is Subtractable by Instant into Duration = _.minus(_)
given Instant is Subtractable by Duration into Instant = _.to(_)
The typeclasses are defined in the companion object of the type of their left
operand, because this is how they will be resolved; not by the right operand.
But notice how it's possible to define two different Subtractable
instances
on Instant
. The correct typeclass will be chosen depending on the type of the
right operand.
Imagine, instead of providing new types with arithmetic operations, we want to
write a generic method which can work with any such type. We should demand the
appropriate typeclass instance in the method's signature, as a using
parameter.
Here's what that might look like for a method that needs to multiply two values of the same type:
def op[Operand, Result](lefts: List[Operand], right: List[Operand])
(using Operand is Multiplicable by Operand into Result)
: List[Result] =
lefts.zip(rights).map(_*_)
We do not need to name the using parameter, as the *
operator will resolve it
automatically.
The nature of these types, composed using the infix type constructors, is
,
by
and into
, is that any part may be omitted when writing the type. For
example, Instant is Addable
is a type, but it's a type which leaves its right
operand and result type unspecified.
Equally, Multiplicable by Int into String
is a type which leaves the left
operand's type unspecified.
Omitting part of the type can be useful, usally with the result type. If we
have an instance of an underspecified type, such as String is Multiplicable by Int
, say mul
, then we can access its Result
type as a member of mul
,
with mul.Result
.
This comes into its own in method signatures of methods which chain together several arithmetic operations. Here's an example which multiplies two pairs of numbers, all potentially different types, then subtracts one product from the other:
def op2[A, B, C, D](a: A, b: B, c: C, d: D)
(using product1: A is Multiplicable by B,
product2: C is Multiplicable by D,
sum: product1.Result is Subtractable by product2.Result)
: sum.Result
a*b - c*d
Two other operations are provided: negation and rooting.
Negation is a unary operator, provided by the Negatable
typeclass. We can
define a Negatable
without a by
clause in its type, for example,
given PosInt is Negatable into NegInt = _.negate()
TBC
Symbolism is classified as embryotic. For reference, Soundness projects are categorized into one of the following five stability levels:
- embryonic: for experimental or demonstrative purposes only, without any guarantees of longevity
- fledgling: of proven utility, seeking contributions, but liable to significant redesigns
- maturescent: major design decisions broady settled, seeking probatory adoption and refinement
- dependable: production-ready, subject to controlled ongoing maintenance and enhancement; tagged as version
1.0.0
or later - adamantine: proven, reliable and production-ready, with no further breaking changes ever anticipated
Projects at any stability level, even embryonic projects, can still be used, as long as caution is taken to avoid a mismatch between the project's stability level and the required stability and maintainability of your own project.
Symbolism is designed to be small. Its entire source code currently consists of 166 lines of code.
Symbolism will ultimately be built by Fury, when it is published. In the meantime, two possibilities are offered, however they are acknowledged to be fragile, inadequately tested, and unsuitable for anything more than experimentation. They are provided only for the necessity of providing some answer to the question, "how can I try Symbolism?".
-
Copy the sources into your own project
Read the
fury
file in the repository root to understand Symbolism's build structure, dependencies and source location; the file format should be short and quite intuitive. Copy the sources into a source directory in your own project, then repeat (recursively) for each of the dependencies.The sources are compiled against the latest nightly release of Scala 3. There should be no problem to compile the project together with all of its dependencies in a single compilation.
-
Build with Wrath
Wrath is a bootstrapping script for building Symbolism and other projects in the absence of a fully-featured build tool. It is designed to read the
fury
file in the project directory, and produce a collection of JAR files which can be added to a classpath, by compiling the project and all of its dependencies, including the Scala compiler itself.Download the latest version of
wrath
, make it executable, and add it to your path, for example by copying it to/usr/local/bin/
.Clone this repository inside an empty directory, so that the build can safely make clones of repositories it depends on as peers of
symbolism
. Runwrath -F
in the repository root. This will download and compile the latest version of Scala, as well as all of Symbolism's dependencies.If the build was successful, the compiled JAR files can be found in the
.wrath/dist
directory.
Contributors to Symbolism are welcome and encouraged. New contributors may like to look for issues marked beginner.
We suggest that all contributors read the Contributing Guide to make the process of contributing to Symbolism easier.
Please do not contact project maintainers privately with questions unless there is a good reason to keep them private. While it can be tempting to repsond to such questions, private answers cannot be shared with a wider audience, and it can result in duplication of effort.
Symbolism was designed and developed by Jon Pretty, and commercial support and training on all aspects of Scala 3 is available from Propensive OÜ.
Symbolism helps work with symbolic operators
In general, Soundness project names are always chosen with some rationale, however it is usually frivolous. Each name is chosen for more for its uniqueness and intrigue than its concision or catchiness, and there is no bias towards names with positive or "nice" meanings—since many of the libraries perform some quite unpleasant tasks.
Names should be English words, though many are obscure or archaic, and it should be noted how willingly English adopts foreign words. Names are generally of Greek or Latin origin, and have often arrived in English via a romance language.
The logo shows three of the four arithmetic operators overlaid upon each other.
Symbolism is copyright © 2024 Jon Pretty & Propensive OÜ, and is made available under the Apache 2.0 License.