Programming Languages · Language Design

Operator Precedence Tables and Why They Are a Design Artifact

How precedence is encoded in a grammar, why the table is not arbitrary, and what goes wrong when a new operator is fitted into an existing hierarchy.

An expression grammar written in the obvious way, with one production for binary operators and one for operands, is ambiguous. a + b * c has two derivations, and the ambiguity is resolved by an ordering the language calls precedence. The ordering looks like a convention, and in a sense it is: * binds tighter than + because the notation of mathematics says so. But precedence is not free-floating taste. It is encoded as a table in the parser, it interacts with associativity, and every addition to it has to be checked against the operators already present.

Precedence is a number attached to a token

In a grammar with precedence declarations, each operator token is given a numeric level and an associativity. The parser generator uses those to break shift-reduce conflicts. When the parser has an operator on the stack and sees another operator as lookahead, it compares the two levels. A higher lookahead level means shift, so the new operator binds tighter and stays on the stack. A lower level means reduce, so the operator on the stack is applied first.

That single comparison produces the entire hierarchy. There is no separate rule that says multiplication is evaluated before addition; there is a level assigned to * above the level assigned to +, and the comparison falls out of it. The table is the grammar’s precedence specification, and the derivation follows.

The levels are arbitrary integers, and only their order matters. A grammar could assign * level 10 and + level 5, or 200 and 100; the parsed language is identical. What cannot be changed is the relative order, and that order is constrained by the operators’ intended algebraic behaviour, not by aesthetics.

Associativity is a separate axis

Precedence answers which of two different operators applies first. Associativity answers what happens between two occurrences of the same operator. a - b - c is (a - b) - c in every language with infix subtraction, which means - is left-associative: the parser reduces when it sees a second operator at the same level. a = b = c is a = (b = c) in C and Java, so = is right-associative: the parser shifts.

The two properties are set independently, and a table that specifies only levels leaves the same-level case undefined. A parser generator that is told only a precedence level has no rule for a - b - c; it needs the associativity as well, and Bison’s declarations express the two together: %left, %right, and %nonassoc each assign a level and a same-level policy. A level declared with %precedence assigns only the level, and a same-level conflict at that level is reported rather than resolved. Declaring = as %left produces (a = b) = c, which is not well typed in most languages, and the error surfaces at the assignment rather than at the declaration.

Exponentiation is the standard counterexample in the other direction. 2 ** 3 ** 2 is 512, not 64, so ** is right-associative. A table that marks it left produces a different number with no diagnostic. Languages that define both an exponentiation operator and a unary minus have to place the two carefully: -2 ** 2 is -(2 ** 2) in Python, because ** binds tighter than unary minus on its left side. That placement is a deliberate choice, documented in the precedence table, and it differs from the mathematical reading some readers expect.

Why the table is not arbitrary

Precedence levels in a language tend to follow the algebraic structure of the operations. Multiplicative operators distribute over additive ones, so multiplication binds tighter. Comparison operators produce booleans and are consumed by logical operators, so comparison binds tighter than &&. Assignment produces a value of the variable’s type and is consumed by expression statements, so it binds loosest of all.

This ordering has a practical consequence: it lets a reader drop parentheses without changing meaning in the cases where the algebra is familiar. a * b + c * d needs no parentheses because the levels encode distributivity. a && b || c needs none because the boolean levels encode the same relationship.

The table is also constrained from below by the parser. A level cannot be added without deciding where it sits relative to every existing level, because the comparison is total. There is no “unrelated” relationship between two operators in a table-driven parser; there is only higher, lower, or equal. An operator whose intended reading is neither tighter nor looser than an existing one has to be given a level anyway, and the resulting language has an opinion about an expression that may never be written.

Fitting a new operator into an old table

The pressure appears when a language adds syntax. Consider a null-coalescing operator meant to be read as “use the right side if the left is null”. It should bind looser than member access, so that a.b ?? c reads the field first. It should bind tighter than assignment, so that x = a ?? b assigns the result. Between those two bounds there may be several existing levels, and the choice among them determines how it interacts with comparison and logical operators.

If the new operator is placed looser than ||, then a || b ?? c groups as (a || b) ?? c. If it is placed tighter, the grouping is a || (b ?? c). Both are defensible; only one matches the reading the language’s users expect. The decision is recorded as a level in the table and is effectively permanent, because changing it later breaks programs that were written without parentheses.

The same problem appears in reverse when an operator is removed or its meaning changes. C’s & and | sit below ==, which means a & b == c parses as a & (b == c). That placement is a widely noted source of bugs; it was inherited from B, where & and | were the logical connectives and therefore had to bind looser than comparison. A language designed later can place them differently, and several do, but the original table cannot move without rejecting programs.

Unary operators and the missing operand

Precedence tables usually cover binary operators, and unary operators are handled by grammar structure instead: a unary production that takes a primary expression as its operand. That structure gives unary operators very high precedence, which is correct for -a * b reading as (-a) * b.

The trouble is a prefix operator that should bind looser than a binary one. A not keyword in a language with == needs to read as not (a == b), but a grammar that puts not in the unary production gives (not a) == b, which is a type error. Languages that have this shape either give not its own production at a lower level, or accept the tighter binding and require parentheses. Python’s not is at a level below comparison, which is why not a == b reads as not (a == b). C’s ! is a unary operator at the highest level, which is why !a == b reads as (!a) == b. Both are consistent with their tables; neither is a mistake.

The case that catches people is a prefix operator applied to a comparison chain. Because Python’s comparison operators chain (a < b < c means a < b and b < c), the chain is a single production, and not sits outside it at a lower level rather than inside it. The grammar therefore cannot produce a < not b < c, and the expression is a syntax error rather than a precedence question. That restriction follows from the chaining rule rather than from precedence, but it is documented in the same table.

Where the table is visible to the programmer

Precedence is normally invisible because the common expressions need no parentheses. It becomes visible in three places. Error messages from a parser report a conflict at a specific token, and the fix is often a parenthesis rather than a grammar change. Formatters that remove redundant parentheses must implement the table exactly, and a formatter with a wrong table will delete a pair that was load-bearing. And language servers that offer “add parentheses” refactorings compute the table to decide when a pair is required.

For a language implementer, the practical rule is to write the table down as data, test it against a corpus of expressions with explicit parentheses, and treat any change as a breaking change. The conflict in The Dangling Else and How Grammars Resolve Ambiguity is resolved by a different mechanism, a conflict policy, but the underlying issue is the same: the grammar text does not by itself determine a unique parse, and the missing information lives in a declaration or a convention outside it.

Tables versus grammar levels

The alternative to a precedence table is a stratified grammar: one production per level, with each level’s operands being the next level up. That form is unambiguous without declarations and is what many hand-written recursive-descent parsers use. Its cost is verbosity: a language with twelve precedence levels needs twelve productions and twelve parse functions, and adding a level means inserting a new production between two existing ones.

The table form is more compact and is what parser generators expect. Its cost is that the grammar no longer shows the hierarchy; the hierarchy is in a separate declaration block, and a reader of the grammar alone cannot tell that * binds tighter than +. Both forms accept the same language. The choice is a tooling choice, and the trade is between a grammar that reads as a description of the language and one that reads as an input to a generator.

Whichever form is used, the numeric levels are a compatibility surface. A language that publishes its precedence table, as Python and C do in their references, has committed to it. The commitment is what allows a reader to drop parentheses with confidence, and it is why an operator added in a later version is placed to fit the table already published, rather than by choosing the most natural algebraic reading in isolation.

The table also fixes only which operator applies first, not when each operand is evaluated. That second question is settled separately, and Evaluation Order, Sequencing Points, and Unspecified Behaviour shows why the two are independent: a * b + c groups as (a * b) + c under any table, while the order in which the three operands are computed, and in which a * b is computed relative to c, may be left open.

References

  1. Bison — GNU parser generator manual, Operator Precedence — GNU Project
  2. The C Programming Language, 2nd edition — Operator Precedence — Kernighan & Ritchie; Prentice Hall
  3. The Python Language Reference — Operator precedence — Python Software Foundation