Consider the following Julia function:
function fun(g, x, y)
g(z * (x + y))
end
What does it do? Well, as it contains the free identifier
z
we cannot know without further context1 Being just one of the reasons why global variables are
frowned upon.. Ideally, a function
should only depend on its inputs enabling local reasoning and better
reuse as it can be applied independent of any further context.
Let’s just fix the above function and pass all free identifiers as arguments2 Due to syntactic restrictions, Julia does not allow to call functions passed as arguments as infix operators.:
function fun2(g, x, y, z, plus, mul)
g(mul(z, plus(x, y)))
end
Correct, the identifiers +
and *
are also
not defined here, i.e., potentially depend on context. While we are used
to avoid global variables, e.g., containing parameter values or
configuration options, functions are almost always named globally.
Again, this restricts reuse and prevents local reasoning, but, in
contrast to variables, function definitions are often well-known, i.e.,
documented as part of the language or library API, and rarely
change.
Yet, as I will show below, modern languages have made functions configurable allowing to change the meaning of a program according to context. In the end, it is precisely this ability to interpret the same program differently that enables generic programming.
Now, that all identifiers are explicitly bound, we have a lot of
flexibilty in using fun2
. First consider the obvious
call
julia> fun2(sqrt, 1, 2, 3, +, *)
3.0
which computes the arithmetic expression \(\sqrt{3 \cdot (1 + 2)}\). As a slightly more involved example, consider the call
julia> fun2(Flux.relu, [1, 2], [1 2; 3 4] \ [-1, 2], [1 2; 3 4], .+ , *)
2-element Vector{Float64}:
4.000000000000001
13.000000000000002
computing – in a somewhat convoluted way – the forward pass of a dense layer \(\mathrm{relu}(W \cdot x + b)\) with
\[ \begin{align*} W &= \left( \begin{array}{cc} 1 & 2 \\ 3 & 4 \end{array} \right) \\ b &= \left( \begin{array}{c} -1 \\ 2 \end{array} \right) \\ x &= \left( \begin{array}{c} 1 \\ 2 \end{array} \right) \; . \end{align*} \]
For the next example, we define
function rplus(r1, r2)
Regex(string("(?:", r1.pattern, ")|(?:", r2.pattern, ")"))
end
and call fun2
as follows:
julia> fun2(r -> match(r, "cb"), r"a", r"b", r"c", rplus, *)
RegexMatch("cb")
Thus, now plus
constructs a regular expression that
macthes either of its arguments and mul
concatenates
regular expressions, i.e., matches when both of its arguments match
sequentially.
In all of our example calls, we would have obtained the same result
when fun
had used the expression z*x + z*y
instead of z*(x + y)
. I.e., assuming distributivity of
*
over +
as suggested by the mathematical
notation. Depending on the interpretation this law might not hold
though: In contrast to the first examples using arithmetic on numbers
and matrices/vectors, the regular expression returned from
fun2(identity, r"a", r"b", r"c", rplus, *)
could
distinguish between the two versions of fun
– even though
the resulting expression would match the same strings3 As the distributive law \(\mathcal{L}_{z(x|y)} =
\mathcal{L}_{(zx)|(zy)}\) holds for the language recognized by
the formal regular expressions constructed via concatenation and
alternation \(|\)..
Generic programming relies on the fact that the same program can be
interpreted differently – just as our example function fun2
above. In most programming languages, polymorphism enables this
kind of flexibility by coupling the meaning of functions/operators to
the data types or classes of the values passed as arguments. This is
indeed a very good idea as the data types often provide enough context
to pick an unambiguous interpretation of operations.
In Julia, all functions can be extended with new methods, i.e., defined for novel combinations of data types. Thereby, programs become configurable by passing different argument types:
function fun(g, x, y, z)
g(z*(x + y))
end
In case of numeric and matrix/vector arguments the required methods
for +
and *
are already defined and
accordingly, the calls
julia> fun(Flux.relu, [1, 2], [1 2; 3 4] \ [-1, 2], [1 2; 3 4])
2-element Vector{Float64}:
4.000000000000001
13.000000000000002
julia> fun(sqrt, 1, 2, 3)
3.0
work as expected. For regular expressions, concatenation is already
available4 Try @which r"a" * r"b"
in the Julia REPL., but alternation needs to be defined5 Extending Base.+
on existing types is
considered bad practice, i.e., “type piracy”, and only used for
illustrative purposes here.:
julia> Base.:+(r1::Regex, r2::Regex) = rplus(r1, r2)
julia> fun(identity, r"a", r"b", r"c")
r"(?:c)(?:(?:a)|(?:b))"
Current programming languages differ in their ability to (re)configure programs after these have been written:
In C or Python, only a single global function definition exists, i.e., disallowing multiple configurations.
OOP languages allow for polymorphism based on the type of the
object receiving the method, i.e., restricting polymorphism to a single
argument and sometimes, methods have to be explicitly marked for
extension (e.g. declared as virtual
in C++).
Some statically compiled languages support generic programming given that the required configuration can be decided at compile time, i.e., via template metaprogramming (C++) or typeclasses (Haskell).
Similarly, Scala has an interesting take on configuration with implicit parameters which are lexically scoped and matched by the compiler based on types.
Monads and effect systems allow to configure side-effects6 While monads are usually implemented by compile-time constructs, modern effect systems resemble exception handling constructs which are commonly dynamically scoped., i.e., with user-defined combinations and interactions between different types of effects, e.g., assignment and non-determinism.
Multiple dispatch as in Julia or Common Lisp allows to extend methods at runtime. Lisp in addition supports dynamically scoped variables enabling further configuration at runtime7 Especially, dynamically scoped functions have been shown to provide similar ways of (runtime) configuration as discussed in aspect-oriented programming..
While generic functions allow different implementations of the same function, e.g., depending on the argument types, the underlying language semantics can usually not be changed. As an example consider Symbolics.jl enabling symbolic computations:
julia> using Symbolics
julia> @variables x, y
2-element Vector{Num}:
x
y
julia> fun(sqrt, x, y, 3)
sqrt(3x + 3y)
While the fun
example works, the following will
fail:
julia> fac(n) = if n < 1 1 else n * fac(n-1) end
fac (generic function with 1 method)
julia> fac(x)
ERROR: TypeError: non-boolean (Num) used in boolean context
The reason being that a symbolic interpretation of an if
clause would require non-standard semantics, i.e., in order to construct
an expression containing both branches, then as well as
else, of the if expression.
If we had control over conditionals and function calls as well, the following would work:
julia> fun(5)
120
julia> fun(x)
:(if x < 1
1
else
x*recur(x - 1)
end)
We can indeed make this work, by introducing functions for
conditionals8 In most languages conditionals are special forms, i.e.,
with non-standard evaluation semantics and cannot be user-defined or
extended. Notable exceptions being Haskell, which has lazy evaluation
anyways, and Smalltalk, which actually defines if as a regular method
taking code blocks as arguments, i.e., just like fif
. and recursive calls.
fif(x, y, z) = if x y() else z() end
fif(x::Num, y, z) = :(if $x; $(y()) else $(z()) end)
recur(f, x) = f(x)
@register_symbolic recur(it)
recur(f, x::Num) = recur(x)
fac(n) = fif(n < 1, () -> 1, () -> n * recur(fac, n-1))
Extensibility is always a trade-off as fewer properties can be assumed for more general code. I.e., if every construct could be extended arbitrarily, without knowing the actual evaluation context, nothing about the program semantic would be known. For that reason, most languages include special forms and other language constructs which cannot be defined or changed by user code. In particular, compiler passes, such as for type inference or optimizations, can usually not be changed via user code9 In principle, macros enable similar transformations. Yet, emulating compiler passes is difficult at the level of the abstract syntax tree and requires at least a code-walker, i.e., a program tracking the semantics – with respect to evaluation and scoping rules – of every expression..
In the end, generic functions are harder to read and write as
functions defined on single types. In particular, they require
abstractions clarifying the properties of functions that can and cannot
be assumed. I.e., in Julia *
can usually be considered as a
monoid operation. In contrast, +
should only be extended if
a group operation or action can be defined10 In Haskell, typeclasses commonly state which
(algebraic) laws can be assumed to hold for their operations. Thereby,
formalizing the required abstractions required to understand generic
code.:
julia> "ab" * one(String) * "c" # Monoid operation
"abc"
julia> [1, 2, 3] - [1, 2, 3] == zero([1,2,3]) # Group operation
true
julia> Date(2023, 1, 1) + Month(4) + Day(3) # Group action
2023-05-04
Due to method definitions, using
a library not just
provides new types and functions, but also provides novel contexts for
existing generic functions. Thereby extending and changing the behavior
of already existing code which arguably explains the “unreasonable
effectiveness of multiple-dispatch”. On the other hand, it is also the
main reason that “type piracy”, i.e., defining new methods for generic
functions and types from other libraries/modules, should be avoided as
this might break existing code. Similarly, violating implicitly assumed
abstractions might lead to correctness problems. Despite these issues11 Statically typed languages like Haskell provide
somewhat stronger guarantees, yet type class laws cannot be
automatically checked yet. Similarly, the Rust compiler disallows memory
errors, race conditions as well as type piracy., the
configurability of generic functions is very powerful and often allows
to combine functionalities in novel ways, i.e., reinterpreting existing
programs in novel contexts. As a convinicing example try
julia> using MonteCarloMeasurements
julia> using Distributions
julia> σ = 1.2 ± 0.1 # uncertain standard deviation
1.2 ± 0.1
julia> quantile.(Normal(0, σ), [0.1, 0.5, 0.9]) # correspondingly uncertain quantiles
3-element Vector{Measurement{Float64}}:
-1.54 ± 0.13
0.0 ± 0.0
1.54 ± 0.13
or just watch The unreasonable effectiveness of multiple dispatch and try to teach your old code new tricks via configurable programming. Maybe also your language of choice has something to offer in this respect …