Skip to content
academia.sh

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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close