A list of ternary operators
Why let conditionals keep hogging the spotlight?
Sup nerds, I'm back from SREcon! I had a blast, despite knowing nothing about site reliability engineering and being way over my head in half the talks. I'm trying to catch up on The Book and contract work now so I'll do something silly here: ternary operators.
Almost all operations on values in programming languages fall into one of three buckets:
- Unary operators, where the operator goes before or after exactly one argument. Examples are
x++
and-y
and!bool
. Most languages have a few critical unary operators hardcoded into the grammar. They are almost always symbols, but sometimes are string-identifiers (not
). - Binary operators, which are placed between exactly two arguments. Things like
+
or&&
or>=
. Languages have a lot more of these than unary operators, because there's more fundamental things we want to do with two values than one value. These can be symbols or identifiers (and
). - Functions/methods that prefix any number of arguments.
func(a, b, c)
,obj.method(a, b, c, d)
, anything in a lisp. These are how we extend the language, and they almost-exclusively use identifiers and not symbols.1
There's one widespread exception to this categorization: the ternary operator bool ? x : y
.2 It's an infix operator that takes exactly three arguments and can't be decomposed into two sequential binary operators. bool ? x
makes no sense on its own, nor does x : y
.
Other ternary operators are extremely rare, which is why conditional expressions got to monopolize the name "ternary". But I like how exceptional they are and want to compile some of them. A long long time ago I asked Twitter for other ternary operators; this is a compilation of some applicable responses plus my own research.
(Most of these are a bit of a stretch.)
Stepped Ranges
Many languages have some kind of "stepped range" function:
# Python
>>> list(range(1, 10, 2))
[1, 3, 5, 7, 9]
There's the "base case" of start and endpoints, and an optional step. Many languages have a binary infix op for the base case, but a few also have a ternary for the optional step:
# Frink
> map[{|a| a*2}, (1 to 100 step 15) ]
[2, 32, 62, 92, 122, 152, 182]
# Elixir
> IO.puts Enum.join(1..10//2, " ")
1 3 5 7 9
This isn't decomposable into two binary ops because you can't assign the range to a value and then step the value later.
Graph ops
In Graphviz, a basic edge between two nodes is either the binary node1 -> node2
or the ternary node1 -> node2 [edge_props]
:
digraph G {
a1 -> a2 [color="green"]
}
Graphs seem ternary-friendly because there are three elements involved with any graph connection: the two nodes and the connecting edge. So you also see ternaries in some graph database query languages, with separate places to specify each node and the edge.
# GSQL (https://docs.tigergraph.com/gsql-ref/4.1/tutorials/gsql-101/parameterized-gsql-query)
SELECT tgt
FROM start:s -(Friendship:e)- Person:tgt;
# Cypher (https://neo4j.com/docs/cypher-manual/current/introduction/cypher-overview/)
MATCH (actor:Actor)-[:ACTED_IN]->(movie:Movie {title: 'The Matrix'})
Obligatory plug for my graph datatype essay.
Metaoperators
Both Raku and J have special higher-order functions that apply to binary infixes. Raku calls them metaoperators, while J calls them adverbs and conjugations.
# Raku
# `a «op» b` is map, "cycling" shorter list
say <10 20 30> «+» <4 5>
(14 25 34)
# `a Rop b` is `b op a`
say 2 R- 3
1
NB. J
NB. x f/ y creates a "table" of x f y
1 2 +/ 10 20
11 21
12 22
The Raku metaoperators are closer to what I'm looking for, since I don't think you can assign the "created operator" directly to a callable variable. J lets you, though!
h =: +/
1 2 h 3 4
4 5
5 6
That said, J has some "decomposable" ternaries that feel spiritually like ternaries, like amend and fold. It also has a special ternary-ish contruct called the "fork".3 x (f g h) y
is parsed as (x f y) g (x h y)
:
NB. Max - min
5 (>. - <.) 2
3
2 (>. - <.) 5
3
So at the top level that's just a binary operator, but the binary op is constructed via a ternary op. That's pretty cool IMO.
Assignment Ternaries
Bob Nystrom points out that in many languages, a[b] = c
is a ternary operation: it is not the same as x = a[b]; x = c
.
A weirder case shows up in Noulith and Raku (again): update operators. Most languages have the +=
binary operator, these two have the f=
ternary operator. a f= b
is the same as a = f(a, b)
.
# Raku
> my $x = 2; $x max= 3; say $x
3
Arguably this is just syntactic sugar, but I don't think it's decomposable into binary operations.
Custom user ternaries
Tikhon Jelvis pointed out that Agda lets you define custom mixfix operators, which can be ternary or even tetranary or pentanary. I later found out that Racket has this, too. Objective-C looks like this, too, but feels different somehow.
Near Misses
All of these are arguable, I've just got to draw a line in the sand somewhere.
- Regular expression substitutions:
s/from/to/flags
seems like a ternary, but I'd argue it a datatype constructor, not an expression operator. - Comprehensions like
[x + 1 | x <- list]
: looks like the ternary[expr1 | expr2 <- expr3]
, butexpr2
is only binding a name. Arguably a ternary if you can map and filter in the same expression a la Python or Haskell, but should that be considered sugar for - Python's operator chaining (
1 < x < 5
): syntactic sugar for1 < x and x < 5
. - Someone suggested glsl swizzles, which are very cool but binary operators.
Why are ternaries so rare?
Ternaries are somewhat more common in math and physics, f.ex in integrals and sums. That's because they were historically done on paper, where you have a 2D canvas, so you can do stuff like this easily:
10
Σ n
n=0
We express the ternary by putting arguments above and below the operator. All mainstream programming languages are linear, though, so any given symbol has only two sides. Plus functions are more regular and universal than infix operators so you might as well write Sum(n=0, 10, n)
. The conditional ternary slips through purely because it's just so darn useful. Though now I'm wondering where it comes from in the first place. Different newsletter, maybe.
But I still find ternary operators super interesting, please let me know if you know any I haven't covered!
Blog Rec
This week's blog rec is Alexis King! Generally, Alexis's work spans the theory, practice, and implementation of programming languages, aimed at a popular audience and not an academic one. If you know her for one thing, it's probably Parse, don't validate, which is now so mainstream most people haven't read the original post. Another good one is about modeling open-world systems with static types.
Nowadays she is far more active on Programming Languages Stack Exchange, where she has blog-length answers on reading type notations, compiler design, and why arrows.
-
Unless it's a lisp. ↩
-
Or
x if bool else y
, same thing. ↩ -
I say "ish" because trains can be arbitrarily long:
x (f1 f2 f3 f4 f5) y
is something I have no idea how to parse. ↩
If you're reading this on the web, you can subscribe here. Updates are once a week. My main website is here.
My new book, Logic for Programmers, is now in early access! Get it here.
From SQL:
expr1 between expr2 and expr3
There is also this which I think is still in the latest ANSI standard:
substring(expr1 from expr2 for expr3)
There's a bunch of other odd syntaxes along these lines.
And aggregates and window functions, not sure how you'd classify them:
aggregate_fn(exrp1 order by expr2,expr3, ...) filter where expra1
window_fn(expr1,expr2, ... partition by expra1, expra2, ... order by exprb1, exprb2, ...)
(And window functions have frame clauses too.)
Would you count this as a ternary operator:
expr1 join expr2 on expr3
Also, I think Agda has user definable ternary operators.
"But I still find ternary operators super interesting, please let me know if you know any I haven't covered!"
I feel like switch and/or match are polynary operators. The thing you're trying providing at the top is one operand and each case you try to match against is another operand which you can add as many of as you'd like.
Smalltalk has unary and binary operators, and adds "keyword" operators of the form "o1 foo: o2 bar: o3", read "foo colon bar colon", and that have two or more operands. In fact, the language has no built-in conditional, using keyword operators in expressions like "boolObject ifTrue: closureObject1 ifFalse: closureObject2" instead.
Some (most?) of these don't strike me as ternary at all – but, after giving it a bit of thought, I think I see. To take a Raku example: in
<10 20 30> «+» <4 5>
, you're thinking of the« »
operator as having three operands (<10 20 30>
,+
, and<4 5>
). Is that right?I ask because that's not how I conceptualize it. I'd say that
« »
is unary circumfix operator with the operand+
. That operation returns a new binary infix operator, which takes<10 20 30>
and<4 5>
as its two operators.And Raku actually does let you decompose this process by assigning the new operator to a function (just as you showed in J). Admittedly, the syntax for doing so is slightly obscure and more than slightly ugly. So it's definitely not something the language really leads you to. Here's how that'd look:
my &h = &[«+»]; <10 20 30> [&h] <4 5>
(This uses the
[&…]
syntax to call a function as if it were an infix operator; you could also explicitly create an infixh
operator:my &infix:<h> = &[«+»]; <10 20 30> h <4 5>
.)Assigning works exactly the same with the assignment metaop:
my &h = &[max=]
. And the same "it a unary op that returns a binary op" logic applies.More generally, I'm not 100% convinced that there's any inherent reason that any ternary operator can't be decomposed. Sticking to Raku
(if False { 'one' } else { 'two' })
looks a lot like a ternary. But(if False { 'one'})
is perfectly valid and returns()
. It seems that any ternary could allow decomposition, even if most of them – including Raku's$a ?? $b !! $c
conditional ternary – don't. What am I missing?