C · lesson 5 of 25
Operators in C
C arithmetic, bitwise, shift, logical and ternary operators with real output, including negative modulo and the precedence bugs -Wall catches.
By SkillByExample · Updated
Arithmetic Operators
The five arithmetic operators behave as you’d expect until integer division and negative numbers get involved:
#include <stdio.h>
int main(void) {
int a = 17, b = 5;
printf("%d + %d = %d\n", a, b, a + b);
printf("%d - %d = %d\n", a, b, a - b);
printf("%d * %d = %d\n", a, b, a * b);
printf("%d / %d = %d\n", a, b, a / b);
printf("%d %% %d = %d\n", a, b, a % b);
printf("-%d / %d = %d\n", a, b, -a / b);
printf("-%d %% %d = %d\n", a, b, -a % b);
printf("(double)%d / %d = %.4f\n", a, b, (double)a / b);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o arith arith.c
$ ./arith
17 + 5 = 22
17 - 5 = 12
17 * 5 = 85
17 / 5 = 3
17 % 5 = 2
-17 / 5 = -3
-17 % 5 = -2
(double)17 / 5 = 3.4000
Integer division throws away the fraction, truncating toward zero, so -17 / 5 is -3, not -4. % is defined so that (a / b) * b + a % b == a always holds, which forces -17 % 5 to be -2. Python gives 3 for the same expression. Code that uses % to wrap an index must handle negative inputs, for example with ((i % n) + n) % n. %% in the format string prints a literal percent sign.
Increment, Decrement, and Compound Assignment
Prefix ++x increments and then yields the new value. Postfix x++ yields the old value and then increments:
#include <stdio.h>
int main(void) {
int x = 5;
int a = ++x; /* prefix: increment, then use */
int b = x++; /* postfix: use, then increment */
printf("x=%d, a=%d, b=%d\n", x, a, b);
int n = 10;
n += 5; printf("n += 5 -> %d\n", n);
n -= 3; printf("n -= 3 -> %d\n", n);
n *= 2; printf("n *= 2 -> %d\n", n);
n /= 4; printf("n /= 4 -> %d\n", n);
n %= 4; printf("n %%= 4 -> %d\n", n);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o incdec incdec.c
$ ./incdec
x=7, a=6, b=6
n += 5 -> 15
n -= 3 -> 12
n *= 2 -> 24
n /= 4 -> 6
n %= 4 -> 2
a and b are both 6, but for different reasons. ++x bumped x from 5 to 6 and handed back 6. x++ handed back that same 6 and only then bumped x to 7.
Don’t Modify a Variable Twice in One Expression
#include <stdio.h>
int main(void) {
int i = 1;
i = i++ + ++i; /* modifies i twice with no sequence point */
printf("i = %d\n", i);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o seqpoint seqpoint.c
seqpoint.c: In function 'main':
seqpoint.c:5:7: warning: operation on 'i' may be undefined [-Wsequence-point]
5 | i = i++ + ++i; /* modifies i twice with no sequence point */
| ~~^~~~~~~~~~~
seqpoint.c:5:7: warning: operation on 'i' may be undefined [-Wsequence-point]
$ ./seqpoint
i = 4
GCC printed 4, but no answer is correct: this is undefined behavior, and another compiler or optimization level may print something else. C does not specify the order in which the operands of + are evaluated. Changing i more than once in a single expression is off-limits. Split it into separate statements.
Relational and Logical Operators
Comparisons produce the int values 1 (true) or 0 (false). && and || short-circuit: they stop evaluating as soon as the result is known.
#include <stdio.h>
int expensive_check(void) {
printf(" (expensive_check ran)\n");
return 1;
}
int main(void) {
int a = 5, b = 10;
printf("a == b: %d, a < b: %d, a >= b: %d\n", a == b, a < b, a >= b);
int x = 0;
if (x != 0 && 10 / x > 1) { /* division never reached when x == 0 */
printf("unreachable\n");
}
printf("guarded division skipped safely\n");
printf("|| with true left side:\n");
if (1 || expensive_check()) printf(" result: true\n");
printf("|| with false left side:\n");
if (0 || expensive_check()) printf(" result: true\n");
printf("!0 = %d, !5 = %d, !!5 = %d\n", !0, !5, !!5);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o logical logical.c
$ ./logical
a == b: 0, a < b: 1, a >= b: 0
guarded division skipped safely
|| with true left side:
result: true
|| with false left side:
(expensive_check ran)
result: true
!0 = 1, !5 = 0, !!5 = 1
expensive_check ran only once, when the left side of || was false and the answer still depended on it. The x != 0 && guard works the same way: division by zero would crash the program, and short-circuiting guarantees it never happens. !!5 is a common idiom for turning any non-zero value into exactly 1.
Bitwise Operators
Bitwise operators work on the individual bits of integers. Standard C17 has no binary literal and no printf conversion for binary, so this example writes its values in hex and uses a small helper to print the bits:
#include <stdio.h>
void print_bits(const char *label, unsigned char v) {
printf("%-7s = ", label);
for (int i = 7; i >= 0; i--) {
putchar((v >> i) & 1 ? '1' : '0');
}
printf(" (%3d)\n", v);
}
int main(void) {
unsigned char a = 0xB4; /* 1011 0100 */
unsigned char b = 0xCA; /* 1100 1010 */
print_bits("a", a);
print_bits("b", b);
print_bits("a & b", a & b); /* 1 where both are 1 */
print_bits("a | b", a | b); /* 1 where either is 1 */
print_bits("a ^ b", a ^ b); /* 1 where they differ */
print_bits("~a", ~a); /* every bit flipped */
print_bits("a << 2", a << 2); /* shifted left, top bits lost */
print_bits("a >> 2", a >> 2); /* shifted right, zeros shift in */
return 0;
}
$ gcc -Wall -Wextra -Wpedantic -std=c17 -o bitwise bitwise.c
$ ./bitwise
a = 10110100 (180)
b = 11001010 (202)
a & b = 10000000 (128)
a | b = 11111110 (254)
a ^ b = 01111110 (126)
~a = 01001011 ( 75)
a << 2 = 11010000 (208)
a >> 2 = 00101101 ( 45)
Before any bitwise operation, a is promoted to a full int. a << 2 is really 720, and passing it to the unsigned char parameter keeps only the low 8 bits, 208. The two top bits fell off the end. 0b10110100 literals and a %b printf conversion are standardized in C23. GCC accepts them in C17 mode as an extension, but -Wpedantic flags them: “binary constants are a C23 feature or GCC extension”.
Flag Patterns: Set, Clear, Toggle, Test
These four idioms appear everywhere in systems code that packs on/off states into one integer:
#include <stdio.h>
#include <stdint.h>
#define STATUS_READY (1u << 0)
#define STATUS_BUSY (1u << 1)
#define STATUS_ERROR (1u << 2)
#define STATUS_TIMEOUT (1u << 3)
int main(void) {
uint8_t status = 0;
status |= STATUS_READY | STATUS_ERROR; /* set two bits */
printf("after set: 0x%02X\n", status);
status &= ~STATUS_ERROR; /* clear one bit */
printf("after clear: 0x%02X\n", status);
status ^= STATUS_BUSY; /* toggle a bit */
printf("after toggle: 0x%02X\n", status);
printf("ready? %s\n", (status & STATUS_READY) ? "yes" : "no");
printf("error? %s\n", (status & STATUS_ERROR) ? "yes" : "no");
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o flags flags.c
$ ./flags
after set: 0x05
after clear: 0x01
after toggle: 0x03
ready? yes
error? no
0x05 is binary 0101: bits 0 (READY) and 2 (ERROR). Clearing uses &= ~mask, where the ~ builds a mask with every bit set except the one to clear. The 1u makes each constant unsigned, so shifting into the top bit of a 32-bit value can’t hit signed-overflow undefined behavior.
Shift Operators
Shifting left by N multiplies by 2^N. Shifting an unsigned value right by N divides by 2^N. Negative numbers are where it gets interesting:
#include <stdio.h>
int main(void) {
unsigned int x = 1;
printf("1 << 1 = %u, 1 << 4 = %u, 1 << 8 = %u\n", x << 1, x << 4, x << 8);
unsigned int y = 256;
printf("256 >> 1 = %u, 256 >> 4 = %u, 256 >> 8 = %u\n", y >> 1, y >> 4, y >> 8);
int neg = -16;
printf("-16 >> 2 = %d\n", neg >> 2); /* implementation-defined */
printf("-16 / 4 = %d\n", neg / 4);
printf("-17 >> 2 = %d, -17 / 4 = %d\n", -17 >> 2, -17 / 4);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o shifts shifts.c
$ ./shifts
1 << 1 = 2, 1 << 4 = 16, 1 << 8 = 256
256 >> 1 = 128, 256 >> 4 = 16, 256 >> 8 = 1
-16 >> 2 = -4
-16 / 4 = -4
-17 >> 2 = -5, -17 / 4 = -4
On GCC, right-shifting a negative int copies the sign bit in (an arithmetic shift), so -16 >> 2 is -4. The standard only calls this implementation-defined. Even here, >> 2 and / 4 disagree for -17: the shift rounds toward negative infinity (-5), while division truncates toward zero (-4). Use unsigned types for bit manipulation, and write / when you mean division. The compiler turns division by a power of two into a shift, plus a fix-up for negatives, on its own.
The Ternary Operator
condition ? a : b is an expression that yields one of two values, handy for short conditional values:
#include <stdio.h>
int max(int a, int b) {
return (a > b) ? a : b;
}
const char *classify(int n) {
return (n > 0) ? "positive" : (n < 0) ? "negative" : "zero";
}
int main(void) {
printf("max(3, 9) = %d\n", max(3, 9));
int values[] = {7, -2, 0};
for (int i = 0; i < 3; i++) {
printf("%d is %s\n", values[i], classify(values[i]));
}
int items = 1;
printf("%d item%s\n", items, items == 1 ? "" : "s");
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o ternary ternary.c
$ ./ternary
max(3, 9) = 9
7 is positive
-2 is negative
0 is zero
1 item
Because ?: is an expression, not a statement, it can appear inside a printf argument list, as in the plural-s trick. The chained form in classify reads well only while it stays this short. Beyond two conditions, switch to if/else.
The Comma Operator
The comma operator evaluates its left operand, throws the result away, then evaluates and yields its right operand. Its main legitimate home is a for loop header:
#include <stdio.h>
int main(void) {
for (int i = 0, j = 4; i < j; i++, j--) {
printf("i=%d, j=%d\n", i, j);
}
int x = (printf("side effect\n"), 42);
printf("x = %d\n", x);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o comma2 comma2.c
$ ./comma2
i=0, j=4
i=1, j=3
side effect
x = 42
i++, j-- is the comma operator: both updates run each iteration. The comma in int i = 0, j = 4 is not. That’s declaration syntax, the same as int a, b;. The parentheses around (printf(...), 42) are required, because without them the comma would separate declarators and the line would not compile.
Operator Precedence
Higher rows bind tighter. When in doubt, add parentheses. They cost nothing and remove ambiguity for the reader too.
| Precedence | Operators | Associativity |
|---|---|---|
| 1 (highest) | () [] -> . postfix ++ -- | Left to right |
| 2 | ! ~ prefix ++ -- unary + - * & sizeof casts | Right to left |
| 3 | * / % | Left to right |
| 4 | + - | Left to right |
| 5 | << >> | Left to right |
| 6 | < <= > >= | Left to right |
| 7 | == != | Left to right |
| 8 | & | Left to right |
| 9 | ^ | Left to right |
| 10 | | | Left to right |
| 11 | && | Left to right |
| 12 | || | Left to right |
| 13 | ?: | Right to left |
| 14 | = += -= etc. | Right to left |
| 15 (lowest) | , | Left to right |
Two rows cause most real bugs: shifts sit below +, and & sits below ==.
#include <stdio.h>
int main(void) {
int flags = 0x06; /* bits 1 and 2 set */
printf("2 + 3 * 4 = %d\n", 2 + 3 * 4);
printf("1 << 2 + 1 = %d\n", 1 << 2 + 1);
printf("(1 << 2) + 1 = %d\n", (1 << 2) + 1);
if (flags & 0x04 == 0x04) printf("buggy test: bit 2 set\n");
else printf("buggy test: bit 2 NOT set\n");
if ((flags & 0x04) == 0x04) printf("fixed test: bit 2 set\n");
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o precedence precedence.c
precedence.c: In function 'main':
precedence.c:7:40: warning: suggest parentheses around '+' inside '<<' [-Wparentheses]
7 | printf("1 << 2 + 1 = %d\n", 1 << 2 + 1);
| ^~
precedence.c:10:15: warning: suggest parentheses around comparison in operand of '&' [-Wparentheses]
10 | if (flags & 0x04 == 0x04) printf("buggy test: bit 2 set\n");
| ^
$ ./precedence
2 + 3 * 4 = 14
1 << 2 + 1 = 8
(1 << 2) + 1 = 5
buggy test: bit 2 NOT set
fixed test: bit 2 set
Bit 2 is set in 0x06, yet the first test says it isn’t. C parsed flags & 0x04 == 0x04 as flags & (0x04 == 0x04), which is flags & 1, and bit 0 of 0x06 is clear. The program compiled and ran with no error. Only the -Wparentheses warning (on by default under -Wall) pointed at it, which is a strong argument for never ignoring warnings.
Practice
- Write
is_even(int n)using a bitwise operator instead of%, and test it on -3 to 3.
Solution
#include <stdio.h>
int is_even(int n) { return (n & 1) == 0; }
int main(void) {
for (int n = -3; n <= 3; n++) {
printf("%2d: %s\n", n, is_even(n) ? "even" : "odd");
}
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p1 p1.c
$ ./p1
-3: odd
-2: even
-1: odd
0: even
1: odd
2: even
3: odd
An even number’s lowest bit is always 0. That holds for negatives too, because C23 requires two’s complement and every mainstream C17 compiler already uses it. The parentheses around n & 1 matter: without them, n & 1 == 0 means n & (1 == 0), which is always 0.
- Write
count_bits(unsigned int v)that returns how many bits are set to 1.
Solution
#include <stdio.h>
int count_bits(unsigned int v) {
int count = 0;
while (v) {
count += v & 1;
v >>= 1;
}
return count;
}
int main(void) {
unsigned int tests[] = {0, 1, 7, 0xB4, 0xFFFFFFFF};
for (int i = 0; i < 5; i++) {
printf("%u has %d bits set\n", tests[i], count_bits(tests[i]));
}
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p2 p2.c
$ ./p2
0 has 0 bits set
1 has 1 bits set
7 has 3 bits set
180 has 4 bits set
4294967295 has 32 bits set
The parameter must be unsigned. With a negative int, >>= would keep shifting the sign bit back in, v would never reach 0, and the loop would never end.
- Convert 3725 seconds into
HH:MM:SSusing only/and%.
Solution
#include <stdio.h>
int main(void) {
int total_seconds = 3725;
int hours = total_seconds / 3600;
int minutes = total_seconds % 3600 / 60;
int seconds = total_seconds % 60;
printf("%d seconds = %02d:%02d:%02d\n", total_seconds, hours, minutes, seconds);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p3 p3.c
$ ./p3
3725 seconds = 01:02:05
% and / share a precedence level and group left to right, so total_seconds % 3600 / 60 means (total_seconds % 3600) / 60: the leftover seconds after whole hours, turned into minutes. %02d pads each field to two digits with a leading zero.
- Swap two integers using XOR, with no temporary variable. Predict the result first.
Solution
#include <stdio.h>
int main(void) {
int a = 5, b = 9;
a ^= b;
b ^= a;
a ^= b;
printf("a = %d, b = %d\n", a, b);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p4 p4.c
$ ./p4
a = 9, b = 5
XOR undoes itself (x ^ y ^ y == x), so three XORs swap the values. It’s a classic interview trick, but it’s not faster than a temporary on modern compilers. It also zeroes the value if both operands are the same variable, as in a swap through two pointers to the same location.