<< Home

Chapter 4: Data Types and Variables

C programs work with values of specific types. Each type controls how many bytes a value uses, how it is represented in memory, the operations that make sense, and how input and output behave. This chapter introduces the fundamental arithmetic types, the common modifiers and qualifiers, rules for scope and lifetime, and how conversions and promotions occur during expression evaluation.

⚠️ Exact sizes for the standard integer and floating types are implementation defined. Always check sizeof at compile time and the macros in <limits.h> and <float.h>.

Primitive types

C defines arithmetic types for integers and real numbers. The core set that appears in almost every program includes char, int, float, and double. There is also long double for higher precision where supported.

char and character representation

char is an integer type large enough to hold any basic execution character. It can be signed or unsigned; this is implementation defined. Character constants like 'A' and escape sequences like '\n' have type int in older code and type int or char depending on context; prefer using unsigned char for raw byte data.

// Inspect the signedness of char at compile time
#include <stdio.h>

int main(void) {
  if ((char)-1 < 0) {
  printf("char is signed\n");
  } else {
  printf("char is unsigned\n");
  }
  return 0;
}
💡 If you process arbitrary bytes (for example file data), use unsigned char. This avoids negative values that can surprise bitwise code.

Integer types built around int

Integer types come in sizes related to short, int, long, and long long. Each can be signed or unsigned. The rank of these types controls promotions and conversions later in this chapter.

Category Examples Typical width Size check at compile time
Small signed char, unsigned char, short 8 to 16 bits sizeof(signed char) == 1 by definition
Regular int, unsigned int 16 to 32 bits sizeof(int) is implementation defined
Wide long, unsigned long, long long 32 to 64 bits sizeof(long), sizeof(long long)
⚠️ Do not assume int is 32 bits. On some systems long is 32 bits and on others it is 64 bits. Use <stdint.h> types like int32_t and uint64_t when you need exact widths.

Floating types

Floating types usually follow IEEE 754 binary formats. The precision and range are available as macros such as FLT_DIG, DBL_MAX, and LDBL_EPSILON from <float.h>. Conversions between integers and floating values are covered in section c04d.

#include <float.h>
#include <stdio.h>

int main(void) {
  printf("float digits: %d\n", FLT_DIG);
  printf("double max: %e\n", DBL_MAX);
  return 0;
}

Inspecting sizes and limits

Use sizeof and the limits headers to discover properties at compile time and run time.

#include <stdio.h>
#include <limits.h>
#include <float.h>

int main(void) {
  printf("sizeof(int) = %zu\n", sizeof(int));
  printf("INT_MAX = %d\n", INT_MAX);
  printf("DBL_MIN = %e\n", DBL_MIN);
  return 0;
}

Type modifiers and qualifiers

Modifiers change the range or precision of a type. Qualifiers change how objects of that type may be accessed or updated. Modifiers bind to arithmetic types. Qualifiers bind to any type.

Using signed and unsigned integers

signed integers represent negative and positive values. unsigned integers represent non negative values only. Conversions between the two are value preserving when the source fits the destination; otherwise results depend on the representation and can wrap modulo two to the power of the width.

#include <stdio.h>

int main(void) {
  unsigned int u = 4000000000u;
  int s = (int)u;  // implementation defined for values that do not fit
  printf("%u %d\n", u, s);
  return 0;
}
⚠️ Mixing signed and unsigned types in comparisons can produce unexpected results because the signed value can convert to unsigned. The comparison then uses a large unsigned number.

Using const for read only intent

const marks an object as not modifiable through that name. It does not make the value a compile time constant by itself. Pointers to const and const pointers are different forms; the position of const matters.

int x = 10;
const int cx = 20;          // cannot write through cx
int *p = &x;                // pointer to int
const int *pc = &x;         // pointer to const int (data is read only through pc)
int * const cp = &x;        // const pointer to int (pointer is fixed)
const int * const cpc = &x; // const pointer to const int
💡 Read pointer declarations right to left. For example int * const is a const pointer to int. This simple rule reduces confusion with multiple qualifiers.

Using volatile for externally changed data

volatile tells the compiler that a value can change outside the program flow. The compiler then avoids certain optimizations and always performs an actual read or write. This is used for memory mapped device registers, flags set by signal handlers, and shared memory where synchronization occurs across translation units.

extern volatile unsigned int status_reg;

while ((status_reg & 1u) == 0u) {
  /* wait for ready flag set by hardware … */
}
⚠️ volatile does not provide atomic operations or mutual exclusion. Use appropriate synchronization primitives for concurrency needs.

Variable scope and lifetime

Scope tells where a name is visible. Storage duration tells when an object exists in memory. Linkage tells whether a name refers to the same object across translation units. All three affect how you design interfaces and manage memory.

Types of scope

Block scope applies to identifiers declared inside braces. Function scope applies to labels used with goto. File scope applies to identifiers declared at the top level of a translation unit.

int g = 1;     // file scope, external linkage by default

void f(void) {
  int x = 2;   // block scope, automatic storage
  {
  int x = 3;   // inner block hides outer x
  printf("%d\n", x);
  }
  printf("%d\n", x);
}

Working with storage duration

Automatic storage duration objects are created on entry to the enclosing block and destroyed on exit. Static storage duration objects exist for the entire program run. Allocated storage is obtained by malloc and friends and must be released by free.

#include <stdlib.h>

void counter(void) {
  static int calls = 0;  // static storage, value persists
  calls++;
  /* use calls … */
}

int *make_array(size_t n) {
  int *p = malloc(n * sizeof *p);
  if (!p) { return NULL; }
  return p;
}
💡 Prefer sizeof *p over sizeof(type) in allocations. This stays correct if the pointed type changes.

Linkage types

Identifiers with external linkage refer to the same entity across translation units. Internal linkage restricts a name to the current translation unit. No linkage means each declaration is a distinct entity.

// file a.c
int shared = 42;        // external linkage
static int hidden = 7;  // internal linkage

// file b.c
extern int shared;      // refers to the same object defined in a.c
⚠️ Functions have external linkage by default. Use static for internal linkage when a function is private to a file.

Type conversions and promotion rules

Expressions often combine different types. C applies a set of promotions and conversions to produce a common type. Understanding these rules prevents subtle bugs, especially with signed and unsigned mixes and with floating and integer combinations.

Integer promotions

Types with rank less than int promote to int if int can represent all values of the original type; otherwise they promote to unsigned int. This happens for char, signed char, unsigned char, short, and unsigned short when used in expressions.

unsigned char a = 200;
unsigned char b = 100;
printf("%d\n", a + b);  // both promote, addition occurs as int

Usual arithmetic conversions

When binary operators combine different arithmetic types, both operands convert to a common real or integer type. If either operand is floating, the other converts to the widest floating type present. Otherwise both operands undergo integer promotions and then convert to the type with the higher rank and signedness rules.

If either is Then convert both to
long double long double
double double
float float
Otherwise apply integer promotions, then convert to the type with higher rank; if ranks are equal and exactly one is unsigned, convert to the unsigned type

Signed and unsigned interactions

If an unsigned type has rank greater than or equal to the signed type, the signed value converts to unsigned. This can change negative numbers into large positive values.

#include <stdio.h>

int main(void) {
  int s = -1;
  unsigned int u = 1u;
  if (s < u) {
  printf("implementation and ranks decide …\n");
  }
  printf("s + u = %u\n", s + u);  // operands convert to unsigned int
  return 0;
}
⚠️ Left shift of a negative value or shift that discards significant bits can have undefined behavior. Be careful when mixing signed shifts with promotions.

Casts, truncation, and overflow

Explicit casts request a conversion. Converting from a wider integer to a narrower one can truncate. Converting a floating value to an integer rounds toward zero and is undefined if the value is outside the range of the destination type.

#include <math.h>

double d = 3.9;
int i = (int)d;                         // becomes 3
unsigned char c = (unsigned char)1025;  // truncation, keeps low 8 bits

Default argument promotions in variadic calls

Arguments to variadic functions such as printf undergo default promotions. float promotes to double and integer types smaller than int promote to int or unsigned int. Format specifiers must match the promoted types.

#include <stdio.h>

void demo(void) {
  float f = 1.0f;
  printf("%f\n", f);   // ok, f promotes to double
  char ch = 'A';
  printf("%d\n", ch);  // ok, ch promotes to int
}
💡 Use PRIu64 and related macros from <inttypes.h> for portable printf with fixed width integers. Example: printf("%" PRIu64 "\n", value);

Balancing precision and performance

double often gives better numerical stability than float at a modest cost. When you must interoperate with graphics or signal processing code that expects float buffers, convert at the edges and keep computations in double internally.