Procedural Programming with C · Basic · Week 7

Type Conversions

When operands of different types meet, C converts them before doing anything. The rules are fixed, silent, and applied to every expression you write. This is the week that explains bugs which compile without a warning and behave perfectly until the day they do not.

By the end of this week you can
  • Apply integer promotion and the usual arithmetic conversions to predict an expression's type.
  • Explain why comparing a signed and an unsigned value can produce a mathematically wrong answer.
  • Use a cast deliberately, and recognize when a cast is hiding a bug rather than fixing one.
  • Describe what happens when a value does not fit the type it is assigned to.
  • Compare two floating-point numbers correctly, and detect NaN and infinity.

1Conversions happen whether you ask or not

C is a statically typed language with a permissive attitude: rather than rejecting an expression whose operands differ, it converts them to a common type and proceeds. These implicit conversions occur in four places:

  • In an arithmetic or comparison expression, to bring both operands to one type.
  • On assignment, to convert the right-hand value to the left-hand type.
  • When passing an argument to a function, to match the parameter type.
  • On return, to match the function's declared return type.

Most of the time this is convenient and correct. The remainder of this week is about the cases where it is not.

2Integer promotion

Before any arithmetic, operands smaller than int are converted to int. This applies to char, signed char, unsigned char, short, unsigned short, and _Bool.

The reason is historical and practical: early processors had no arithmetic on sub-word operands, so the language defined arithmetic to happen at word width.

char a = 100;
char b = 100;
char c = a + b;      /* a and b promoted to int: 200. Stored back
                        into a char, which cannot hold 200. */

The addition itself is fine — it happens in int and yields 200. The damage is done on assignment, where 200 is squeezed back into a signed char.

Promotion also explains something that puzzles beginners:

printf("%zu\n", sizeof(char));      /* 1 */
printf("%zu\n", sizeof('A'));       /* 4 — in C, 'A' is an int  */
printf("%zu\n", sizeof(char) + 1);  /* 8? no: size_t arithmetic */

A character constant such as 'A' has type int in C, not char. This differs from C++, and it occasionally matters.

3The usual arithmetic conversions

Once both operands are at least int, C brings them to a common type by walking down this list and stopping at the first case that applies:

StepRule
1If either is long double, convert the other to long double.
2Otherwise if either is double, convert the other to double.
3Otherwise if either is float, convert the other to float.
4Otherwise both are integers: apply integer promotion, then convert toward the type of greater rank.
5If the two have equal rank but differ in signedness, the signed one is converted to unsigned.

Step 5 is the one that draws blood. The rest are intuitive: mixing an int with a double gives a double, which is what anyone would expect.

Worked through:

int    i = 3;
double d = 2.0;
i / 2          /* int / int    -> int    -> 1   */
i / 2.0        /* int / double -> double -> 1.5 */
i / d          /* int / double -> double -> 1.5 */
(i + 1) / 2    /* 4 / 2        -> int    -> 2   */

4The signed-unsigned trap

Here is the single most notorious conversion bug in C:

int      a = -1;
unsigned b = 1;

if (a < b) {
    puts("-1 is less than 1");     /* never printed */
} else {
    puts("-1 is NOT less than 1"); /* this one runs */
}

Both operands have rank int, and one is unsigned, so rule 5 applies: a is converted to unsigned int. In two's complement, −1 becomes 4 294 967 295. That number is not less than 1, so the comparison is false — and it is correct according to the language. The bug is in the program, not the compiler.

The same mechanism turns loops into infinite loops:

size_t n = 0;
for (size_t i = 0; i < n - 1; i++) {
    /* n - 1 is unsigned. 0 - 1 wraps to SIZE_MAX.
       This loop runs about 18 quintillion times. */
}

And it is why strlen returning size_t deserves care:

if (strlen(s) - 1 >= 0)   /* always true: unsigned is never negative */

Defences

  • Compile with -Wsign-compare, which -Wextra enables. It flags exactly this.
  • Do not mix signedness in one expression. Pick a convention per function and convert at the boundary.
  • Rearrange rather than subtract: write i + 1 < n instead of i < n - 1.
  • Use unsigned types for quantities that genuinely cannot be negative — sizes, indices, bit patterns — and signed types for everything else.

Unsigned arithmetic never overflows; it wraps. That is defined behavior, computed modulo 2N. It is not an error, nothing is reported, and the program continues with a wrong value. Signed overflow, by contrast, is undefined behavior — see week 36. Neither is a safety net.

5Explicit conversion: the cast

A cast forces a conversion:

double average = (double)total / count;

Placement matters entirely. (double)total / count converts total first, so the division is double / intdouble. (double)(total / count) performs integer division first and then widens the already-truncated result. The parentheses are the whole difference between a correct average and a wrong one.

When a cast is right

  • Forcing floating-point division between integers, as above.
  • Silencing a legitimate narrowing where you have already checked the range.
  • Converting void * to a typed pointer — week 30.
  • Selecting an overload of a standard function, for example (double)x before a printf("%f").

When a cast is wrong

A cast tells the compiler "I know what I am doing." If you do not, the cast merely deletes the warning that would have told you. A cast added to make a warning go away, without a reason you could state out loud, is a bug with the alarm disconnected.

/* Wrong: hides a real narrowing problem */
short s = (short)some_large_int;

/* Right: the check makes the cast honest */
if (some_large_int >= SHRT_MIN && some_large_int <= SHRT_MAX) {
    short s = (short)some_large_int;
}

6When the value does not fit

ConversionWhat happens
Larger integer → smaller integerThe high-order bits are discarded. For unsigned targets this is defined modulo arithmetic; for signed targets it is implementation-defined.
Negative → unsignedDefined: add 2N. −1 becomes the maximum value.
double → integerThe fractional part is discarded — truncated toward zero, not rounded. If the value is out of range, the behavior is undefined.
Integer → floatMay lose precision. A 32-bit int has more significant digits than a float can hold.
doublefloatRounded to the nearest representable float; may become infinity.

Note the truncation rule for floating point to integer: (int)3.99 is 3 and (int)-3.99 is −3. To round, use round, floor, or ceil from <math.h> — and remember to link with -lm.

7Comparing floating-point values

Week 4 established that most decimal fractions have no exact binary representation. The consequence for comparison is direct: never use == on floating-point values unless you are comparing against a value you know to be exactly representable, such as 0.0 assigned literally.

The naive fix, and why it is not enough

if (fabs(a - b) < 1e-9) { … }   /* absolute tolerance */

This works when the numbers are near 1. It fails at both extremes: for values around 10⁻¹⁵ every pair compares equal, and for values around 10¹⁵ no pair ever does, because the gap between adjacent representable doubles is already larger than 10⁻⁹.

A tolerance that scales

#include <math.h>
#include <float.h>
#include <stdbool.h>

static bool nearly_equal(double a, double b, double relative)
{
    double diff = fabs(a - b);
    if (diff <= DBL_EPSILON) {      /* handles a == b and both near zero */
        return true;
    }
    double scale = fmax(fabs(a), fabs(b));
    return diff <= relative * scale;
}

Choosing relative is a judgement about your problem, not a property of the language. For values derived from a handful of arithmetic steps, something around 1e-9 for double is reasonable. For a long accumulation, error grows and the tolerance must grow with it.

Accumulated error

double sum = 0.0;
for (int i = 0; i < 10; i++) {
    sum += 0.1;
}
/* sum is 0.99999999999999988898, not 1.0 */

Each addition rounds, and the errors do not cancel. Where exactness matters — money, above all — do not use floating point. Store amounts as integer minor units (kuruş, cents) and format for display.

NaN and infinity

#include <math.h>

double x = 0.0 / 0.0;    /* NaN      */
double y = 1.0 / 0.0;    /* infinity */

isnan(x)      /* true  */
isinf(y)      /* true  */
isfinite(y)   /* false */
(x == x)      /* false — NaN is not equal to itself */

That last line is the defining property of NaN and a legitimate test for it, though isnan says what you mean. It also means a NaN quietly poisons every comparison: a < b, a > b, and a == b are all false when either is NaN, so a sort can behave bizarrely if one slips into the data.

8Worked example: two bugs that compile cleanly

Save as convert.c.

#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <string.h>
#include <math.h>
#include <float.h>
#include <limits.h>

static bool nearly_equal(double a, double b, double relative)
{
    double diff = fabs(a - b);
    if (diff <= DBL_EPSILON) {
        return true;
    }
    double scale = fmax(fabs(a), fabs(b));
    return diff <= relative * scale;
}

int main(void)
{
    puts("== bug 1: a comparison that is wrong but not incorrect ==");
    int      signed_value   = -1;
    unsigned unsigned_value = 1;
    printf("  -1 < 1u  evaluates to %d\n", signed_value < unsigned_value);
    printf("  because -1 converted to unsigned is %u\n",
           (unsigned)signed_value);
    printf("  fix: compare in a signed type -> %d\n",
           signed_value < (int)unsigned_value);

    puts("\n== the loop that would never end ==");
    const char *empty = "";
    size_t length = strlen(empty);
    printf("  strlen(\"\") = %zu\n", length);
    printf("  length - 1 = %zu   <-- wrapped\n", length - 1);
    puts("  so  for (i = 0; i < length - 1; i++)  would run ~1.8e19 times");
    puts("  fix: write  i + 1 < length  instead");

    puts("\n== bug 2: floating point equality ==");
    double a = 0.1, b = 0.2;
    printf("  0.1 + 0.2        = %.20f\n", a + b);
    printf("  == 0.3           -> %s\n", (a + b == 0.3) ? "true" : "false");
    printf("  nearly_equal     -> %s\n",
           nearly_equal(a + b, 0.3, 1e-9) ? "true" : "false");

    double sum = 0.0;
    for (int i = 0; i < 10; i++) {
        sum += 0.1;
    }
    printf("  0.1 added ten times = %.20f\n", sum);
    printf("  == 1.0           -> %s\n", (sum == 1.0) ? "true" : "false");
    printf("  nearly_equal     -> %s\n",
           nearly_equal(sum, 1.0, 1e-9) ? "true" : "false");

    puts("\n== promotion and narrowing ==");
    char small_a = 100, small_b = 100;
    printf("  100 + 100 as int  = %d\n", small_a + small_b);
    printf("  stored in a char  = %d   <-- did not fit\n",
           (char)(small_a + small_b));

    printf("\n  (int)3.99  = %d      (truncates, never rounds)\n", (int)3.99);
    printf("  (int)-3.99 = %d\n", (int)-3.99);
    printf("  lround(3.99) = %ld\n", lround(3.99));

    puts("\n== the average, done wrong and right ==");
    int total = 7, count = 2;
    printf("  total / count             = %d\n", total / count);
    printf("  (double)(total / count)   = %g   <-- cast too late\n",
           (double)(total / count));
    printf("  (double)total / count     = %g   <-- correct\n",
           (double)total / count);

    puts("\n== NaN and infinity ==");
    double nan_value = 0.0 / 0.0;
    double inf_value = 1.0 / 0.0;
    printf("  0.0/0.0 = %f, isnan -> %d, equals itself -> %d\n",
           nan_value, isnan(nan_value), nan_value == nan_value);
    printf("  1.0/0.0 = %f, isinf -> %d\n", inf_value, isinf(inf_value));

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

The -lm is required: <math.h> declares fabs and isnan, but their implementations live in the math library, which the linker does not search unless told. Omit it and you get the undefined reference error from week 2.

Read the warnings first

convert.c:24:44: warning: comparison of integer expressions of different
                 signedness: 'int' and 'unsigned int' [-Wsign-compare]

That one warning is the entire subject of section 4, delivered by the compiler for free. A project that compiles with warnings switched on and then ignores them has paid the cost of the tooling without collecting the benefit.

9Common mistakes

MistakeWhat happensFix
Comparing int with size_tNegative values become huge; comparison invertsKeep one signedness per expression; heed -Wsign-compare.
i < length - 1 when length is 0Wraps to SIZE_MAX; loop never endsWrite i + 1 < length.
(double)(a / b)Integer division already truncated(double)a / b.
if (x == 0.1)Almost always falseCompare with a scaled tolerance.
Adding a cast to silence a warningThe bug stays, the alarm is goneUnderstand the warning; cast only when you can state why.
Using double for moneyAccumulated rounding errorStore integer minor units.
Forgetting -lmundefined reference to 'fabs'Link the math library.
Assuming (int)x rounds3.99 becomes 3Use lround, floor, or ceil.

10Check yourself

Why is -1 < 1u false?

Both operands have the rank of int and one is unsigned, so the usual arithmetic conversions convert the signed operand to unsigned. In two's complement −1 becomes 4 294 967 295, which is not less than 1. The comparison is behaving exactly as specified; the defect is mixing signedness in the expression.

What is the difference between (double)total / count and (double)(total / count)?

The first converts total before dividing, so the division is performed in double and keeps the fraction. The second divides two integers — truncating immediately — and then widens the already-wrong result. Only the first computes an average correctly.

Is char c = a + b; with a and b both 100 an error?

Not a compile error. The operands are promoted to int, the sum 200 is computed correctly, and then it is converted back to char on assignment. If plain char is signed and 8 bits, 200 does not fit and the result is implementation-defined. The arithmetic was never the problem; the storage was.

Why is an absolute tolerance such as fabs(a-b) < 1e-9 not a general solution?

Because the spacing between representable doubles grows with magnitude. Near 10¹⁵ that spacing already exceeds 10⁻⁹, so no two distinct values ever compare equal; near 10⁻¹⁵ almost every pair does. A tolerance must scale with the size of the values being compared.

A sort of your double array produces nonsense. One element is NaN. Why does that break it?

Every comparison involving NaN is false — <, >, and == alike. A comparison function therefore reports that the NaN is neither before nor after nor equal to anything, which violates the ordering the sorting algorithm assumes and can corrupt the whole result. Filter or reject NaN before sorting.

11Where this leads

You can now predict the type and value of any expression. Week 8 uses that directly: printf and scanf take a format string whose specifiers must match the argument types exactly, and the conversions from this week are precisely what decides whether they do. It is also where you meet the first function that can corrupt memory if you get an argument wrong.