Procedural Programming with C · Basic · Week 6

Operators and Expressions

Operators are the part of C that looks most familiar and hides the most surprises. Precedence decides what an expression means; evaluation order decides when each piece happens — and C deliberately leaves much of the second unspecified.

By the end of this week you can
  • Use every arithmetic, relational, and logical operator correctly, including % with negative operands.
  • Predict the result of an expression from the precedence and associativity tables.
  • Explain the difference between i++ and ++i and when it matters.
  • Explain why short-circuit evaluation is a guarantee you can rely on, and argument evaluation order is not.
  • Recognize an expression whose behavior the standard does not define.

1Arithmetic operators

OperatorMeaningExampleResult
+addition7 + 29
-subtraction7 - 25
*multiplication7 * 214
/division7 / 23 (integers!)
%remainder7 % 21

Integer division truncates

If both operands are integers, / performs integer division and discards the fractional part. 7 / 2 is 3, not 3.5, and not 4 — the result is truncated toward zero, never rounded.

To get a real quotient, at least one operand must be floating point:

7 / 2          /* 3     */
7.0 / 2        /* 3.5   */
(double)7 / 2  /* 3.5   */
(double)(7/2)  /* 3.0 — too late, truncation already happened */

% works on integers only

7.5 % 2 does not compile. For floating point, use fmod from <math.h>.

The sign of the remainder follows the dividend: -7 % 2 is −1, not 1. This is a consequence of truncating division, and it means % is not a modulo operator in the mathematical sense. A common test for evenness, n % 2 == 1, therefore fails for negative odd numbers; write n % 2 != 0 instead.

Division by zero is undefined behavior, not an error value. Integer x / 0 typically crashes the program with a hardware exception. Floating-point division by zero is different: IEEE 754 defines it, and 1.0 / 0.0 yields infinity. Never assume C will hand you a convenient sentinel — check the divisor.

2Relational and logical operators

OperatorMeaning
== !=equal, not equal
< <= > >=ordering
&&logical and
||logical or
!logical not

A relational operator produces int 1 for true and 0 for false. In a condition, zero is false and everything else is true — so if (count) means "if count is not zero".

Short-circuit evaluation is guaranteed

&& and || evaluate their left operand first, and if that settles the answer, the right operand is never evaluated at all. This is not an optimization; it is required by the standard, and correct C relies on it constantly.

if (divisor != 0 && total / divisor > 10) { … }

If divisor is zero, the division never happens. Reverse the two tests and the program crashes. From week 13 onward the same pattern guards pointers:

if (p != NULL && p->count > 0) { … }

The comma operator and the conditional operator also impose an order. Everything else — notably the operands of +, *, and the arguments of a function call — does not.

= is assignment; == is comparison. if (x = 5) assigns 5 to x and then tests 5, which is true — always. It is legal C, so only a warning saves you. -Wall emits suggest parentheses around assignment used as truth value. Some programmers write if (5 == x) so that a typo becomes a compile error; the modern answer is to trust the warning and never ignore it.

3Assignment, compound assignment, increment

Assignment in C is an expression, not a statement: it has a value, which is the value assigned. That is why a = b = 0 works — it groups right to left.

Compound assignment

total += 5;     /* total = total + 5  */
total -= 5;
total *= 2;
total /= 2;
total %= 3;

These are not merely shorthand. In arr[next_index()] += 1 the left operand is evaluated once; writing it out longhand would call next_index() twice.

Increment and decrement

++ and -- each come in two forms that differ in what the expression yields:

int i = 5;
int a = i++;   /* a = 5, i = 6 — post: yields the old value */

int j = 5;
int b = ++j;   /* b = 6, j = 6 — pre:  yields the new value */

When the value is discarded — as in the third clause of a for loop — the two are identical, and i++ is conventional. When the value is used, choose deliberately.

An old myth. You may read that ++i is faster than i++ because the latter must keep a copy. For an int on any compiler released this century, the generated code is identical — you can confirm this yourself with the -S technique from week 2. The advice has real force in C++ for iterator types; in C it does not.

4Precedence and associativity

Precedence decides which operator binds tighter: in a + b * c, * binds tighter, so it is a + (b * c). Associativity decides the grouping among operators of equal precedence: a - b - c is (a - b) - c because - is left-associative.

PrecedenceOperatorsAssociativity
highest() [] -> . postfix ++ --left to right
unary ! ~ ++ -- + - * & sizeof castsright to left
* / %left to right
+ -left to right
<< >>left to right
< <= > >=left to right
== !=left to right
& then ^ then |left to right
&& then ||left to right
?:right to left
= += -=right to left
lowest,left to right

Two rows in that table are responsible for a disproportionate share of real bugs.

Bitwise operators bind looser than comparison

This is widely regarded as a design mistake in C, preserved for compatibility:

if (flags & MASK == 0)     /* means: flags & (MASK == 0)  — wrong */
if ((flags & MASK) == 0)   /* what you meant                      */

Week 25 uses this constantly. Parenthesize bitwise expressions as a reflex.

Assignment binds looser than almost everything

x = a < b;      /* x = (a < b) — x gets 0 or 1 */

The practical rule. Memorize three levels — unary tightest, then arithmetic, then comparison, then logical, then assignment — and parenthesize everything else. Nobody is impressed by an unparenthesized expression that requires the table to read. Clear beats clever, and the compiler produces identical code either way.

5Evaluation order: the part that is not defined

Precedence tells you how an expression is grouped. It says nothing about the order in which the pieces are computed. These are different questions, and conflating them is a classic source of confusion.

In f() + g(), precedence guarantees the two results are added. It does not guarantee that f runs before g. The compiler may call them in either order, and may choose differently at different optimization levels.

That is merely unspecified — one of a set of allowed behaviors. Worse is available:

int i = 0;
int a = i++ + i++;        /* undefined behavior */
arr[i] = i++;             /* undefined behavior */
printf("%d %d\n", i++, i++);  /* undefined behavior */

The rule: if an expression modifies an object more than once, or modifies it and separately reads it for a purpose other than computing the new value, without an intervening sequence point, the behavior is undefined. Not implementation-defined — undefined. The compiler may produce any result at all.

Where the sequence points are

ConstructOrdering guarantee
; end of a full expressioneverything before completes first
&& and ||left fully evaluated before right; right may be skipped
?:condition evaluated first; exactly one branch runs
, (the comma operator)left fully evaluated, its value discarded, then right
function callall arguments evaluated before the body runs — in unspecified order

Enable -Wall and GCC warns about the obvious cases: operation on 'i' may be undefined. Do not treat that as advisory.

Why the language is like this. Leaving evaluation order open lets the compiler reorder computation to use registers and pipelines efficiently — and in 1972, on a PDP-11, that mattered enormously. The cost is that a small set of expressions have no defined meaning. The fix costs nothing: put each side effect in its own statement.

6Worked example: predict, then verify

Write your answers down before running this. The point of the exercise is the gap between your prediction and the output.

#include <stdio.h>
#include <stdlib.h>

static int trace(const char *label, int value)
{
    printf("  [evaluating %s]\n", label);
    return value;
}

int main(void)
{
    puts("== integer division and remainder ==");
    printf("7 / 2        = %d\n",  7 / 2);
    printf("-7 / 2       = %d\n",  -7 / 2);
    printf("7 %% 2        = %d\n", 7 % 2);
    printf("-7 %% 2       = %d\n", -7 % 2);
    printf("7.0 / 2      = %g\n",  7.0 / 2);

    puts("\n== precedence ==");
    printf("2 + 3 * 4        = %d\n", 2 + 3 * 4);
    printf("(2 + 3) * 4      = %d\n", (2 + 3) * 4);
    printf("10 - 4 - 3       = %d\n", 10 - 4 - 3);
    printf("1 << 2 + 3       = %d\n", 1 << 2 + 3);   /* 1 << (2+3) */
    printf("(1 << 2) + 3     = %d\n", (1 << 2) + 3);

    puts("\n== the bitwise-versus-comparison trap ==");
    int flags = 0x0C;          /* 0000 1100 */
    int mask  = 0x04;          /* 0000 0100 */
    printf("flags & mask == 0    -> %d   (this is flags & (mask == 0))\n",
           flags & mask == 0);
    printf("(flags & mask) == 0  -> %d   (what you meant)\n",
           (flags & mask) == 0);

    puts("\n== pre versus post increment ==");
    int i = 5;
    printf("i++ yields %d, i is now %d\n", i++, i);
    int j = 5;
    printf("++j yields %d, j is now %d\n", ++j, j);

    puts("\n== short-circuit is guaranteed ==");
    int divisor = 0;
    if (divisor != 0 && 100 / divisor > 10) {
        puts("  not reached");
    }
    puts("  survived a division by zero that never happened");

    puts("\n== argument order is NOT guaranteed ==");
    int sum = trace("left", 1) + trace("right", 2);
    printf("  sum = %d\n", sum);
    printf("  rerun with -O2 and the two lines may swap\n");

    return EXIT_SUCCESS;
}
gcc -std=c17 -Wall -Wextra -g -o expr expr.c
./expr

What the compiler says before you even run it

expr.c:31:24: warning: suggest parentheses around comparison in operand of '&'
              [-Wparentheses]
expr.c:40:5: warning: operation on 'i' may be undefined [-Wsequence-point]

Both warnings point at real defects. The second is worth dwelling on: printf("… %d … %d", i++, i) reads i and modifies it within one expression with no sequence point between, so the output is not merely platform-dependent — it is undefined. Fix it by separating the statements:

int old = i;
i++;
printf("i++ yields %d, i is now %d\n", old, i);

The experiment that makes the point

Rebuild at two optimization levels and compare the last section's output:

gcc -std=c17 -O0 -o expr0 expr.c && ./expr0 | tail -4
gcc -std=c17 -O2 -o expr2 expr.c && ./expr2 | tail -4

GCC on x86 typically evaluates function arguments right to left, so you will likely see [evaluating right] first — on both builds, or on only one. Either outcome teaches the lesson: the sum is always 3, but when each side effect happens is the compiler's business, not yours.

7Common mistakes

MistakeWhat happensFix
if (x = 5)Assigns, then always tests true== for comparison. Never ignore -Wparentheses.
flags & MASK == 0Parses as flags & (MASK == 0)Parenthesize bitwise expressions always.
n % 2 == 1 to test oddFalse for negative odd numbersn % 2 != 0.
average = total / count with two intsTruncated result(double)total / count.
a = i++ + i++Undefined behaviorOne side effect per statement.
Relying on left-to-right argument evaluationWorks on one compiler, breaks on anotherCompute into named variables first.
Dividing without checking the divisorCrash on integer division by zeroGuard with &&, relying on short-circuit.

8Check yourself

What is -7 / 2 and -7 % 2 in C, and why?

−3 and −1. Integer division truncates toward zero rather than rounding down, so −3.5 becomes −3; the remainder then takes the sign of the dividend so that (a / b) * b + a % b == a still holds. This is why % is a remainder operator, not a mathematical modulo.

Why is if (p != NULL && p->count > 0) safe but the reverse order is not?

Because && guarantees short-circuit evaluation: if the left operand is false, the right is never evaluated, so the null pointer is never dereferenced. Written the other way round, the dereference happens first and the program crashes. This is a language guarantee, not an optimization.

What is the difference between precedence and evaluation order?

Precedence is about grouping — which operands belong to which operator, and therefore what the expression means. Evaluation order is about timing — when each subexpression is actually computed. C fixes the first completely and leaves much of the second to the compiler, which is why f() + g() has a defined value but no defined call order.

Why is arr[i] = i++; undefined rather than merely unpredictable?

Within one expression it both modifies i and reads i for a purpose unrelated to computing the new value, with no sequence point between. The standard does not enumerate the permitted outcomes; it withdraws all requirements, so the compiler may produce anything. Split it into two statements.

When do i++ and ++i behave identically?

Whenever the value of the expression is discarded — most commonly the third clause of a for loop, or a statement consisting of the increment alone. They differ only when the resulting value is used. For an int, the generated machine code is the same either way.

9Where this leads

Every example this week used operands of the same type. Week 7 removes that restriction and asks what C does when an int meets an unsigned, or a char meets a double. The answer is a fixed set of conversion rules that run silently before every operation — and the single most common source of bugs that compile without a warning and behave correctly right up until they do not.