C / TYPES AND REPRESENTATION
Signed and unsigned mixing and comparison traps
Predict and fix comparisons that mix signed and unsigned operands, from size_t lengths to countdown loops and bounds checks that let -1 through.
What you will learn
- Predict any signed/unsigned comparison by naming the common type first
- Rewrite descending loops so an unsigned counter never has to drop below zero
- Guard a cast with an explicit x >= 0 test instead of one bounds check
- Treat -Wsign-compare and 'always true' warnings as real bugs, not noise
Understanding Signed and unsigned mixing and comparison traps
When the two operands of <, >, <= or == have the same rank but differ in signedness, the signed operand is converted to the unsigned type before any comparing happens. That conversion is modular, not clamping: -1 becomes -1 + 2^32 = 4294967295 for a 32-bit unsigned int. So i < u with i == -1 and u == 1 is really 4294967295u < 1u, which is 0. No diagnostic appears at runtime because the conversion is perfectly legal C; only the meaning of your test was lost.
The useful mental model is to stop asking which value is bigger and first ask which type the comparison happens in. Two consequences then follow mechanically: for an unsigned expression, x >= 0 is always true and x < 0 is always false, so range checks written with signed types in mind stop rejecting anything; and subtracting from a small unsigned value yields an enormous value instead of a negative one, so an off-by-one becomes an off-by-four-billion. This bites most often around lengths and indices, because size_t, the type of sizeof and strlen, is unsigned and usually the widest operand in the expression.
Keeping it out of your code is mostly a typing discipline. Pick one type per quantity so mixed comparisons never arise: index and length both size_t, or both int if you also need a sentinel like -1. When you must cross the boundary, check the sign separately, as in if (i >= 0 && (size_t)i < n), so the cast only ever sees values it cannot damage. Widening works too when the signed type is strictly wider than the unsigned one, but that depends on platform widths, so lean on -Wsign-compare instead: the compiler can see every one of these sites and you cannot.
<stdio.h>
int main(void)
{
int i = -1;
unsigned u = 1;
/* Same rank, different signedness: i is converted to unsigned int. */
printf("i = %d, u = %u\n", i, u);
printf("i < u -> %d\n", i < u);
printf("(unsigned)i -> %u\n", (unsigned)i);
printf("(long long)i < (long long)u -> %d\n", (long long)i < (long long)u);
int count = -3;
unsigned len = 5;
if (count < len)
printf("count < len is true\n");
else
printf("count < len is false; count became %u\n", (unsigned)count);
return 0;
}
A comparison between a signed and an unsigned operand of the same rank is performed entirely in unsigned arithmetic, so a negative value becomes an enormous one and the ordering you expected reverses.
Worked examples
The countdown loop that cannot end
Shows why an unsigned loop counter can never be tested against zero and what the counter holds after it wraps.
<stdio.h>
int main(void)
{
int a[3] = {10, 20, 30};
unsigned i;
/* i >= 0 would never be false, so the guard tests the upper bound. */
for (i = 2; i < 3; i--)
printf("a[%u] = %d\n", i, a[i]);
printf("loop left i = %u\n", i);
printf("(i >= 0) = %d\n", i >= 0);
return 0;
}
Example explained
Line 1i-- on 0 does not produce -1; the unsigned value wraps to 4294967295, so only an upper-bound guard like i < 3 can stop the loop.
Line 2Using i >= 0 as the condition would run the loop again and evaluate a[4294967295], which is out of bounds and typically a segfault.
Line 3The last line prints the tautology directly: for an unsigned type i >= 0 is 1 for every representable value, so it carries no information.
Line 4gcc -Wextra flags that line as 'comparison of unsigned expression in >= 0 is always true'.
strlen minus one on an empty string
Demonstrates that size_t arithmetic turns a 'last index' of -1 into SIZE_MAX and defeats a >= 0 sanity check.
<stdio.h>
<string.h>
int main(void)
{
const char *s = "";
size_t n = strlen(s);
printf("n = %zu\n", n);
printf("n - 1 = %zu\n", n - 1);
printf("(int)n - 1 = %d\n", (int)n - 1);
if ((int)n - 1 >= 0)
printf("signed test: last index is %d\n", (int)n - 1);
else
printf("signed test: string is empty\n");
if (n - 1 >= 0)
printf("unsigned test: last index is %zu\n", n - 1);
else
printf("unsigned test: string is empty\n");
return 0;
}
Example explained
Line 1strlen returns size_t, so n - 1 is computed in unsigned arithmetic and with n == 0 it lands on SIZE_MAX instead of -1.
Line 2(int)n - 1 moves the subtraction into int, which is why the first test correctly reports an empty string.
Line 3n - 1 >= 0 compares an unsigned value with 0, so it is true no matter what n was, and the branch prints a 20-digit index that would crash on use.
Line 4The same shape appears as for (i = 0; i < strlen(s) - 1; i++), a loop that should not run at all and instead runs essentially forever.
A bounds check that lets a negative length through
Shows the conversion happening at a function call rather than in the comparison, turning a validated length into SIZE_MAX.
<stdio.h>
<string.h>
CAP
static void copy_into(char *dst, const char *src, int len)
{
if (len > CAP) {
printf("len %d rejected\n", len);
return;
}
printf("len %d accepted; memcpy would get size %zu\n", len, (size_t)len);
if (len > 0)
memcpy(dst, src, (size_t)len);
}
int main(void)
{
char buf[CAP] = {0};
copy_into(buf, "abcdefgh", 4);
copy_into(buf, "abcdefgh", 9);
copy_into(buf, "abcdefgh", -1);
printf("buf = %s\n", buf);
return 0;
}
Example explained
Line 1len > CAP compares int with the int constant 8, so no unsigned conversion happens here and -1 passes the guard unnoticed.
Line 2(size_t)len prints exactly what memcpy's third parameter would receive: the same conversion the compiler performs implicitly at the call.
Line 3The if (len > 0) line is the only reason this program is safe to run; without it memcpy is asked for 16 exabytes.
Line 4The real fix is a separate if (len < 0) return; before the upper-bound test, or declaring the parameter size_t so a negative value cannot reach it.
Important notes
Whether the flip happens depends on relative widths: unsigned int compared with long on 64-bit Linux converts both to long and behaves as you expect, while the same line built with a 32-bit long converts to unsigned long and reverses. The outputs above assume 32-bit int and 64-bit size_t.
Converting a negative int to an unsigned type is fully defined (value + 2^N), which is exactly why the bug is silent; the reverse direction, storing an out-of-range unsigned value into a signed type, was implementation-defined before C23.
Common mistakes
Writing for (size_t i = n - 1; i >= 0; i--): the condition is never false, so after i reaches 0 it wraps to SIZE_MAX and the next a[i] reads far outside the array, usually a segfault; with n == 0 the loop starts out of bounds too.
Validating a signed length against a signed constant and then passing it to a size_t parameter, as in if (len > CAP) return; memcpy(dst, src, len): a negative len passes the check and becomes a gigantic size at the call.
Silencing a -Wsign-compare warning by casting the unsigned side down, as in if (i < (int)n): the warning goes away, but any n above INT_MAX now becomes negative and the test fails in the opposite direction.
Try it yourself
Change, predict, then run
Write reverse_print(const char *s) that prints s backwards using a size_t index, then call it with "abc" and with "". Make the empty case print nothing without the index ever wrapping, using while (i-- > 0) rather than a test against 0.
Open the C workspaceCheck your understanding
A function takes int len, does if (len > 64) return -1; and then calls memcpy(dst, src, len). Why is a call with len == -1 dangerous?
- memcpy clamps a negative size to zero, so the call copies nothing and the only cost is a wasted branch.
- Both operands of len > 64 are int, so -1 > 64 is false and the guard passes; memcpy's size_t parameter then converts -1 to 18446744073709551615.
- The comparison len > 64 converts len to unsigned, so the call is rejected and the danger is only the misleading return value.
- Passing a negative int where size_t is expected is a compile error, so the code never builds in the first place.
Show answer
64 is an int constant, so the guard is an ordinary signed comparison that -1 passes; the signed-to-unsigned conversion happens later, implicitly, when the value is bound to memcpy's size_t parameter. Option 3 is tempting because mixed comparisons are the usual place this bites, but there is nothing unsigned in len > 64, which is why the check must add an explicit len < 0 test.