Language design: gotchas with variadic minus

Treating the minus operator as a function can be tricky and dangerous.

published 2020-Oct-17

TLDR: variadic -, as seen in Lisps, has gotchas; it may be allowed syntactically, but not as a variadic function.

- tends to be overloaded with two different operations: negation and subtraction. Negation is always unary. Subtraction can be variadic. Unary subtraction is an identity function that returns the first argument unchanged without negating it.

ƒ negate(a)         = 0 - a

ƒ subtract(a)       = a
ƒ subtract(a b)     = a - b
ƒ subtract(a b c)   = (a - b) - c
ƒ subtract(a b c d) = ((a - b) - c) - d

In math and many programming languages, there's no ambiguity because - is either unary prefix (negation) or binary infix (subtraction):

-A       |    Negation.
B - C    |    Subtraction.

But in Lisps, - is always prefix, always variadic, and when called with a single argument, it always negates it.

The following examples use Racket. Let's dynamically pass N arguments to -:

#lang racket/base

(define (subtract . args) (apply - args))

(println (subtract 11 33 55))
(println (subtract 11 33))
(println (subtract 11))
-77
-22
-11 ; Performed negation, not subtraction!

The last call performed negation on its only argument.

Correct variadic subtraction:

#lang racket/base

(define (flip fun) (lambda (a b) (fun b a)))
(define (foldl1 fun seq) (foldl fun (car seq) (cdr seq)))
(define (subtract . args) (foldl1 (flip -) args))

(println (subtract 11 33 55))
(println (subtract 11 33))
(println (subtract 11))
-77
-22
11

Now, 11 was correctly returned as-is.

Worth comparing to Haskell, which also generalizes operators into functions, but handles - differently. In Haskell, the function - is always binary subtraction:

main = do
  print (foldl1 (-) [11, 33, 55])
  print (foldl1 (-) [11, 33])
  print (foldl1 (-) [11])
-77
-22
11

Haskell doesn't allow to overload functions on parameter count. You can't define - as both unary and binary. So they special-cased unary - in the syntax, converting it to negate:

main = do
  print (-11)
  print (negate 11)
-11
-11

Lisp and Haskell create this problem for themselves by treating - as a function while overloading it with two different functions. Most languages don't have this problem because they don't have - as a function. Languages with operator overloading tend to differentiate between negation and subtraction. For example, Rust has ops::Neg and ops::Sub. Literal - is converted into calls to one of those. When passing it to a higher-order function, you either pass ops::Neg::neg, or ops::Sub::sub, avoiding the problem completely.