student@ubuntu:~$
c-foundations Lesson 4 10 min read

Operators & Expressions

Arithmetic, comparison, logical, and bitwise operators, plus the = vs == trap

Based on content from Dr. Stu Steiner, Eastern Washington University.

Reading: Hanly & Koffman: §2.5 (pp. 72–86), §4.2 (pp. 175–184)

In a nutshell

The operators in C look almost identical to the ones you used in Java. Almost. Two differences are subtle and destructive: = is an expression, not just a statement, so it sneaks into conditions without a syntax error; and & / && / | / || are four different operators that you are expected to keep straight. This lesson walks through arithmetic, comparison, logical, and bitwise operators, then finishes with the = vs == trap.

Practice this topic: C Operators drill, or browse the practice gallery.

After this lesson, you will be able to:

  • Use arithmetic, comparison, and logical operators in C expressions
  • Predict where integer division truncates and where a result promotes to double
  • Use short-circuit evaluation as a guard against null pointers and out-of-range indices
  • Apply bitwise operators for bit-level manipulation
  • Name the three reasons = vs == is the most destructive silent bug in C

Quick reference

Category Operators Notes
Arithmetic + - * / % / truncates on integer operands
Increment / decrement i++ ++i i-- --i post vs pre matters inside expressions
Comparison < > <= >= == != each yields 1 or 0
Logical && \|\| ! short-circuit evaluation
Bitwise & \| ^ ~ << >> individual bits
Assignment = += -= *= /= %= &= etc. = alone is not comparison
Sizing sizeof(T) or sizeof x compile-time

Coming from CSCD 210

Every operator in the quick reference exists in Java and does roughly the same thing. Three differences to watch for. Integer division truncates in C the same way it did in Java (so 5 / 2 is 2, as you remember). & and && are different operators here: & is bitwise, && is logical. And assignment is an expression in C, which is what lets = slip into conditions without a syntax error.


Arithmetic and comparison

Arithmetic

int a = 7;
int b = 3;

printf("%d\n", a + b);    /* 10 */
printf("%d\n", a - b);    /* 4  */
printf("%d\n", a * b);    /* 21 */
printf("%d\n", a / b);    /* 2, not 2.33, because int / int is int */
printf("%d\n", a % b);    /* 1  */

printf("%d\n", -7 / 2);   /* -3, truncation is toward zero */
printf("%d\n", -7 % 2);   /* -1 */

When you want floating-point division, at least one operand must be double:

double gpa = 3.75;
printf("%.2f\n", gpa * 5.0 / 4.0);   /* 4.69, full precision */
printf("%.2f\n", gpa * (5 / 4));     /* 3.75, because (5/4) evaluates to 1 */

i++, ++i, i--, --i work the same way they did in Java. In a standalone statement the difference does not matter; inside an expression it does, and the safe habit is to put the ++ on its own line.

Comparison and logical

C90 has no separate boolean type. Comparison operators produce an int: 1 for true, 0 for false. Logical operators treat zero as false and any non-zero value as true.

int x = 5, y = 10;

printf("%d\n", x < y);              /* 1 */
printf("%d\n", x == y);             /* 0 */

printf("%d\n", x > 0 && y > 0);     /* 1, logical AND */
printf("%d\n", x > 0 || y < 0);     /* 1, logical OR */
printf("%d\n", !(x > 0));           /* 0, logical NOT */

&& and || are logical; & and | are bitwise. Mixing them up is a silent bug that sometimes gives the right answer on small inputs and the wrong answer on large ones.

Short-circuit evaluation as a safety guard

&& and || evaluate the second operand only when they have to:

  • a && b: if a is false, b is never evaluated.
  • a || b: if a is true, b is never evaluated.

This is not just an optimization; it lets you use the left operand to guard the right. The pattern is everywhere in C:

/* Do not dereference p if it is NULL. */
if (p != NULL && *p == 'A') { ... }

/* Do not index arr[i] unless i is in range. */
if (i < n && arr[i] == target) { ... }

/* Read the file, but only if it opened. */
fp = fopen("data.txt", "r");
if (fp != NULL && fscanf(fp, "%d", &n) == 1) { ... }

Swap the two clauses and the program crashes on the first null pointer or out-of-range index.

Check your understanding (predict the output)

#include <stdio.h>

int main(void)
{
    int x = 0;
    int y = 5;

    if (x != 0 && (10 / x) > 1) {
        printf("branch A\n");
    }

    if ((10 / x) > 1 && x != 0) {
        printf("branch B\n");
    }

    printf("done\n");
    return 0;
}
Reveal answer

Branch A prints nothing (safely). Branch B crashes before any printf runs. Output is typically:

Floating point exception

with no done printed. In branch A, the guard x != 0 is false first, so 10 / x is never evaluated. In branch B, 10 / x is evaluated first, and dividing by zero kills the process before the x != 0 check ever runs. Guard first, use second.


Bitwise, sizeof, and precedence

Bitwise operators

Operator Name Example Result
& AND 0b1100 & 0b1010 0b1000
\| OR 0b1100 \| 0b1010 0b1110
^ XOR 0b1100 ^ 0b1010 0b0110
~ NOT ~0b1100 0b...0011
<< left shift 1 << 3 8
>> right shift 16 >> 2 4
unsigned char flags = 0;

flags |= (1 << 2);    /* set bit 2 */
flags |= (1 << 5);    /* set bit 5 */

if (flags & (1 << 2)) {
    printf("Bit 2 is set\n");
}

flags &= ~(1 << 2);   /* clear bit 2 */

<< by n multiplies unsigned values by 2^n. >> by n divides unsigned values by 2^n. On signed values, right shift is implementation-defined; prefer unsigned types when you rely on shifting.

sizeof

sizeof returns the size in bytes of a type or variable. It is evaluated at compile time; the operand is not executed.

printf("int:    %zu bytes\n", sizeof(int));       /* 4 */
printf("double: %zu bytes\n", sizeof(double));    /* 8 */
printf("char:   %zu bytes\n", sizeof(char));      /* 1 */

int arr[10];
printf("arr:    %zu bytes\n", sizeof(arr));       /* 40 */

%zu is the specifier for size_t. One trap: sizeof(arr) gives 40 when arr is the array as declared, but inside a function that receives int arr[] as a parameter, sizeof(arr) gives the size of a pointer (typically 8). Arrays & Strings covers that in full.

Precedence in practice

You do not need to memorize the full table. Know this list, highest to lowest:

  1. Unary (!, ~, ++, --, cast)
  2. * / %
  3. + -
  4. << >>
  5. < > <= >=
  6. == !=
  7. &
  8. ^
  9. |
  10. &&
  11. ||
  12. Assignment (= += etc.)

Two practical consequences.

Shift binds looser than + and -:

printf("%d\n", 1 << n + 1);   /* parses as 1 << (n + 1), not (1 << n) + 1 */

Bitwise & and | bind looser than ==:

if (x & 0x01 == 0) { ... }    /* parses as x & (0x01 == 0), which is x & 0, which is 0 */
if ((x & 0x01) == 0) { ... }  /* what you meant */

When in doubt, add parentheses. Clear trumps clever.


The = vs == trap

This is the single most common silent bug in C.

int x = 5;

if (x = 0) {          /* BUG: assigns 0 to x, then tests 0 (false) */
    printf("zero\n");
}

if (x == 0) {         /* correct: compares x to 0 */
    printf("zero\n");
}

Three facts make this dangerous:

  1. = is a legal expression. x = 0 stores 0 in x and the whole expression has the value 0. The compiler does not reject it.
  2. Assignment yields a value usable as a condition. if (x = 5) assigns 5 and tests whether 5 is non-zero (it is), so the body always runs.
  3. The variable is silently mutated. After if (x = 0), x is now 0, even though the if body did not run.

The Lab 1 reflection walks through a credit-card approval system that used = instead of == and approved every purchase (CWE-481, mechanism detailed in the CWE deep-dive).

Two habits that protect you:

  • -Wall catches most cases. Wrap intentional assignments in double parentheses (if ((x = getc(f)) != EOF)) to tell the compiler “yes, I meant it.”
  • Yoda conditions. Writing if (0 == x) makes the typo if (0 = x) a compiler error because you cannot assign to a literal.

Check your understanding (what is wrong?)

unsigned char flags = 0x08;    /* bit 3 is set */

if (flags & 1 << 3 == 8) {
    printf("bit 3 is set\n");
}

The printf does not run. Why not, and what are the minimum parentheses to fix it?

Reveal answer

Two precedence issues stack.

  1. == binds tighter than & and <<. So 1 << 3 == 8 parses as 1 << (3 == 8), which is 1 << 0, which is 1.
  2. Then the expression becomes flags & 1, which is 0x08 & 0x01, which is 0.

Fix: explicit parens, or test against non-zero:

if ((flags & (1 << 3)) == 8) { ... }
if (flags & (1 << 3))         { ... }    /* clearer: any non-zero is true */

What comes next

You have the building blocks: variables, I/O, and operators. Next, Control Flow shows how to combine them into branches and loops, including the switch fall-through rule and C90-style for loops that Lab 1 grades. Drill this page: C Operators or the practice gallery.