Lesson 08 / 13
Operators
An operator's result being decided at compile time according to the operands' types; measured through precedence, short-circuit evaluation, compound assignment, and the defined–undefined distinction in evaluation order.
Published Updated
In this lesson
- Measures and shows that an operator's result is selected at compile time according to operand type
- Distinguishes precedence, short-circuit evaluation and compound assignment with examples
- Separates which cases of evaluation order are defined and which are undefined
Contents
The Variables, Scope and Storage Duration lesson measured where a name is visible from and how long a value lives — both were about the state of a name. This lesson moves one step further: it measures what determines the result when two values are combined with an operator. The answer is not the operands’ value but their type — and this choice, just like which declaration a name is bound to in the previous lesson, is decided at compile time. This lesson measures four separate operator families — arithmetic, logical, bitwise and assignment — with the same question: how much of an operator’s result written in the source, or which sub-operations will run to reach that result, is already known before a run even starts?
The Programming Fundamentals course (M01/K02) already established arithmetic and logical operators with Python; there, an operator’s behavior was interpreted mostly by looking at the operands’ value at runtime. C’s counterpart is measured here: which operation an operator runs is fixed at compile time by looking at the type of the operands written in the source, before the operation ever runs.
The Operator’s Choice by Type
The same / operator runs two different operations depending on its operands’ types:
integer division if both are integers, floating-point division if at least one is a
floating-point value. This choice is made not by looking at the data, but at the types
written in the source.
// stage.c — operator choice by type (excerpt)
#include <stdio.h>
int main(void) {
int whole = 7 / 2;
double fractional = 7 / 2.0;
printf("whole %d fractional %.1f\n", whole, fractional);
return 0;
}
whole 3 fractional 3.5
BS5. The only difference written in the source is 2 versus 2.0 — one an integer
constant, the other a floating-point constant. This difference determines at compile time
which division operation runs: 7 / 2 runs integer division and drops the fractional
part, while 7 / 2.0 first promotes 7 to double and runs floating-point division. 7
itself never changes; what changes is which hardware operation the same / operator
corresponds to — while the two-character operator symbol written in the source stays the
same, the machine instruction the compiler produces follows two different paths depending
on the operands’ types. This is a decision in the same family as the integer promotion
measured in the previous lesson: the operator looks at the operands’ declared type, not
their value at runtime.
This rule is not unique to the division operator; the rule the C standard calls the usual
arithmetic conversions works the same way for almost all binary arithmetic operators. If
the two operands’ types differ, the “narrow” side is promoted to the “wide” one, and the
operation runs in the wide type — just as char operands were promoted to int in the
Data Types lesson, here too, when an int and a double are added, the int is
converted to double first. When an expression like whole_count + ratio is written in
the source, which operand is converted in which direction is determined at compile time
by looking at the two sides’ declared types; which values are being added has no effect
at all on this decision.
Precedence: The Source’s Structure Fixed at Compile Time
When an operator runs depends not only on type but also on the source’s structure. Precedence rules determine which of a parenthesis-free expression’s sub-expressions is evaluated before which, and this decision is written entirely in the source itself.
// precedence.c — operator precedence fixes the source's structure at compile time
#include <stdio.h>
int main(void) {
int multiplication_first = 2 + 3 * 4;
int addition_first = (2 + 3) * 4;
printf("%s %d\n", "multiplication first (no parentheses)", multiplication_first);
printf("%s %d\n", "addition first (parenthesized)", addition_first);
return 0;
}
multiplication first (no parentheses) 14 addition first (parenthesized) 20
Both expressions carry the same three numbers (2, 3, 4) and the same two operators
(+, *), but their results differ: multiplication_first runs multiplication before
addition (2 + 12 = 14), while addition_first, thanks to the parentheses, runs
addition first (5 * 4 = 20). This difference is resolved at compile time without
looking at any data, purely by the presence of the parentheses in the source — before a
run ever starts, which sub-expression is evaluated in which order is already known.
Precedence determines not only which operator runs first, but also, when operators of
the same precedence are written side by side, from which direction they group — this is
called associativity. Most binary operators (+, -, *, /) associate left to right:
the expression 10 - 3 - 2 groups as (10 - 3) - 2, not 10 - (3 - 2), and these two
groupings give different results (5 versus 9). The assignment operator, on the other
hand, associates in the opposite direction, right to left: the expression a = b = 5
groups as a = (b = 5), 5 is assigned to b first, then the result of that assignment
is assigned to a. Both precedence and associativity are resolved at compile time,
purely by looking at the arrangement of operators in the source, before a run ever
starts.
Short Circuit: Which Operator Is Chosen Affects the Second Operand
Logical operators (&&, ||) carry a guarantee: if the left operand is enough to
determine the result, the right operand is never evaluated. Bitwise operators (&, |)
do not carry the same guarantee — both operands are always evaluated. Which guarantee
applies is determined at compile time by looking at which operator is written in the
source.
// short_circuit.c — which operator is chosen determines at compile time whether the second operand runs
#include <stdio.h>
static int call_counter = 0;
static int side_effecting(void) {
call_counter++;
return 1;
}
int main(void) {
call_counter = 0;
int and_result = (0 && side_effecting());
printf("%s %d\n", "call counter after the and operator", call_counter);
call_counter = 0;
int bitwise_result = (0 & side_effecting());
printf("%s %d\n", "call counter after the bitwise operator", call_counter);
(void)and_result;
(void)bitwise_result;
return 0;
}
call counter after the and operator 0 call counter after the bitwise operator 1
BS6. Both calls use the same side_effecting function with the same 0 left
operand, but the result differs: with &&, because the left operand is already false,
the right operand is never run at all and call_counter stays at zero; with &, even
though the left operand’s value is enough to determine the result, the operator itself
does not recognize such a shortcut, and side_effecting is called anyway. Which
guarantee applies is a decision that determines whether call_counter will actually be
0 or 1 at runtime — but that decision itself is fixed at compile time, by looking at
which operator was chosen. Both operators would have produced the same logical result
(0) with the same left operand; the difference between them is not in the result, but
in whether the second operand runs.
This distinction becomes important when the goal is to make a call like
side_effecting run only when needed. If the left operand alone is enough to determine
the result (false for &&, true for ||), the short-circuit guarantee makes sure the
right side never runs at all — this is a way of skipping an unnecessary piece of work,
obtained with a single line written in the source. Because bitwise operators do not carry
this guarantee, both operands run in every case; for this reason bitwise operators are
chosen not for a logical condition but when a genuine bit-level combination is needed.
Compound Assignment: Conversion by the Left Side’s Type
A compound assignment operator (like +=, |=) always converts its result to the left
side’s declared type — regardless of the right side’s type.
// assignment.c — the compound assignment result is converted at compile time by the left side's type
#include <stdio.h>
int main(void) {
int whole_number = 5;
whole_number += 2.7;
printf("%s %d\n", "compound assignment on int", whole_number);
unsigned char flag = 0xF0;
flag |= 0x0F;
printf("%s %d\n", "compound assignment on unsigned char", flag);
int factor = 6;
factor *= 2.5;
printf("%s %d\n", "multiplicative assignment on int", factor);
int counter = 10;
counter -= 3;
printf("%s %d\n", "subtraction assignment on int", counter);
return 0;
}
compound assignment on int 7 compound assignment on unsigned char 255 multiplicative assignment on int 15 subtraction assignment on int 7
whole_number += 2.7 works out to the same as whole_number = whole_number + 2.7: the
sum is first computed at double width (5 + 2.7 = 7.7), but because whole_number‘s
type is int, the result is converted to 7 by dropping the fractional part. This
conversion is applied not by looking at the value of 2.7 on the right, but at the
declared type of whole_number on the left — the same truncation rule would apply even
if the right side were 2.1 or 2.9. flag |= 0x0F is a bitwise combination: the
bit-level union of 0xF0 and 0x0F is 0xFF, 255 in decimal. factor *= 2.5 confirms
the same rule once more: 6 * 2.5 = 15.0, a result with an integer value at double
width, and because factor‘s type is int it converts to 15 with no truncation loss
at all — showing that truncation does not always mean a loss, only that the result is
forced into the left side’s type. counter -= 3 is the plain case where both sides
are already the same type and the conversion is invisible. In all four examples, the
result’s type is fixed by the left side’s declared type; this shows that the assignment
operator, just like the division operator, works in a type-driven way.
The Bounding Measurement
Operator precedence determines in which order a source’s sub-expressions group, but it
does not always determine in which order every sub-part of an expression is evaluated.
In a call like f(a, b), which of a and b is computed first is unspecified; if a
and b modify the same variable through a side effect — say, both increment the same
counter — this is a situation the standard classifies as undefined behavior: the same
object is modified twice with no sequence point between them. Under this course’s general
rule, such a call is not tried in a code block.
The defined counterpart set alongside it is the comma operator: two sub-expressions
separated by , are always evaluated left to right, with a full sequence point between
them — the right side does not start until the left side has completed.
// ordering.c — the comma operator guarantees order, function arguments do not
#include <stdio.h>
int main(void) {
int i = 0;
int final_value = (i++, i++);
printf("%s %d\n", "final value with the comma operator", final_value);
printf("%s %d\n", "i with the comma operator", i);
return 0;
}
final value with the comma operator 1 i with the comma operator 2
In the expression (i++, i++), the left-side i++ runs to completion first: it produces
i‘s old value (0) and moves i to 1 — but this value is discarded by the comma
operator. Then the right-side i++ runs: this time it produces i‘s value (now 1) and
moves i to 2; the expression’s overall result is the value the right side produced,
that is, 1. The result is fully predictable and the same on every run: the comma
operator gives exactly the guarantee that a function call’s argument list does not. If
the same i++, i++ pattern were written as a function call’s two arguments (like
f(i++, i++)), the order in which the compiler evaluates the arguments would be
unspecified, and modifying the same i twice would fall into undefined behavior — this
lesson does not try that call, it only measures the comma operator’s guaranteed order.
Another classic example in the same class is an expression like i + i++: there is no
ordering guarantee between the addition operator’s two operands either, and while the
left-side i is read, whether the right-side i++ modifies i before or after that
read is unspecified — the same object is being read and modified, with no ordering
guarantee between them. This is also not run under this course’s general rule. The
common thread here is that most of C’s operators give no ordering guarantee at all
between their operands — ordering is explicitly guaranteed only for a few special
constructs (the comma operator, &&, ||, the condition part of ?:). A source writer
who wants to use the same variable more than once in an expression, modifying it at
least once, must lean on one of these guaranteed constructs or on separate expressions;
otherwise, which order the compiler chooses stays unspecified.
All the decisions measured in this lesson tie back to the same question: is an operator’s result determined at compile time by looking at the operand types and the source’s structure, or at runtime by looking at the operands’ actual value? For division, precedence, short-circuit and compound assignment, the answer was the compiler — which operation runs or which order is followed could be read from the source before the program ever ran. The cases where evaluation order stays undefined are not an exception to this rule but its boundary: where the compiler gives no guarantee, the source writer must build a guarantee of their own.
Summary
- Which operation an operator runs is decided at compile time by looking at the operands’ types written in the source, not their value at runtime — class compiler.
- Operator precedence determines which sub-expressions of a parenthesis-free expression group first, purely from the source’s own structure, without looking at any data; associativity gives the direction of grouping when operators of the same precedence are written side by side.
&&and||carry a short-circuit guarantee,&and|do not; which guarantee applies is fixed at compile time by looking at which operator is written in the source.- Compound assignment operators always convert their result to the left side’s declared type — this conversion is made by looking at the left side’s type, not the right side’s value.
- The evaluation order of function call arguments is unspecified, and modifying the same variable twice with no sequence point between them is undefined behavior; it is not run in this course. The comma operator, by contrast, carries a guaranteed left-to-right order and was run as the defined counterpart.
Next Step
All the decisions measured in this lesson stayed within the boundary of a single
expression: which operation an operator ran, which sub-expression grouped first, which
operand was evaluated. The common trait of these decisions was that the data producing
the result never affected the decision itself — only the types and structure written in
the source were decisive. The Control Flow lesson adds a new question to this ground:
which branch a program follows is now determined not by the source but by data produced
at runtime. In this lesson, which operation a result like multiplication_first came
from could be read from the source; in the next lesson, which code path a branch follows
can only be known once the program runs and produces the data — it cannot be told in
advance by reading the source.
To keep your progress and take notes, Log in
My notes
Log in to take notes.