C Constants: const, #define, and enum

Declare constants three different ways in C — const-qualified variables, #define macros, and enum constants — and explain the tradeoffs between them in terms of type safety, scope, debugger visibility, and whether they exist at compile time or occupy real storage.

Not every value in a program should be changeable. A tax rate, a maximum number of users, a fixed buffer size, a default timeout — these are values you want to name clearly and use repeatedly throughout your code, but that should never accidentally be reassigned somewhere down the line. C gives you three distinct ways to express 'this value is fixed': the const qualifier, the #define preprocessor macro, and enum constants. They look superficially similar but behave quite differently under the hood, and understanding those differences — not just the syntax — is what lets you pick the right one deliberately instead of by habit.

const: A Type-Checked, Read-Only Variable

The const keyword qualifies an ordinary variable declaration, telling the compiler that this variable's value must never change after it is initialized. const int maxUsers = 100; declares a real variable, of a real, specific type (int), that the compiler will actively enforce as read-only: any later line that tries to assign a new value to maxUsers (like maxUsers = 200;) is a compile-time error, caught before the program ever runs, exactly like any other type mismatch. This is one of const's biggest advantages — it isn't just a convention you have to remember to follow; the compiler actively polices it for you.

Because a const variable is a real, typed variable, it behaves like any other variable in every other respect: it follows normal scope rules (block-scoped if declared inside a function, file-scoped if declared at the top level), it occupies real memory (though the compiler is often smart enough to optimize simple cases so it doesn't literally take up space at runtime), and — critically for debugging — it shows up by name, with its correct type, in a debugger. If you set a breakpoint near a line using maxUsers, a tool like lldb (the debugger clang/Xcode use) can show you maxUsers = 100 directly, because as far as the compiler and debugger are concerned, it's simply a variable, just one flagged as unassignable after initialization.

const also plays a specific, important role with pointers (a topic covered in a later module), where it can separately control whether the pointer itself is reassignable and whether the data it points to is mutable — but for now, at the level of plain variables like int, double, or char, the mental model is simple: const means 'declare this variable as usual, but the compiler will refuse to let anyone write to it again after its initial value is set.'

• const int maxUsers = 100; — a real, typed variable the compiler enforces as read-only after initialization. • Reassigning a const variable is a compile-time error, actively caught by the compiler, not just a convention. • const variables follow normal scope rules and occupy real (type-correct) storage. • const variables are visible by name and type in a debugger, unlike macros (see below).

#define: A Preprocessor Text Substitution

#define is not a C statement at all — it's a preprocessor directive, handled entirely in the very first stage of the build pipeline covered in Lesson 1, before the compiler ever sees real C grammar. #define BUFFER_SIZE 256 tells the preprocessor: everywhere the token BUFFER_SIZE appears later in this file, replace it with the literal text 256, purely as a textual find-and-replace, before compilation begins. By the time the compiler actually runs, there is no BUFFER_SIZE symbol anywhere — it has already been erased and replaced with the raw text 256 everywhere it was used.

This has real, sometimes surprising consequences. Because #define performs blind text substitution with no understanding of C's grammar or types, a macro has no type at all — BUFFER_SIZE isn't an int, or anything; it's just text that happens to look like an int once substituted. This means the compiler cannot do the same type-checking on macro usage that it can on a const variable, and it also means macros are invisible to a debugger by name — because they never existed as a symbol after preprocessing, lldb has no BUFFER_SIZE to show you; you'd only ever see the literal number 256 in the debugged code.

Despite these downsides, #define remains genuinely useful, particularly for conditional compilation (#ifdef DEBUG ... #endif, used to include or exclude entire blocks of code depending on build settings) and for defining constants that need to be usable in contexts requiring a true compile-time constant expression outside of C's type system, such as sizing a fixed-size array at declaration (char buffer[BUFFER_SIZE];) in strict C89-era code. But by convention, macro constant names are written in ALL_CAPS with underscores specifically so that, reading the code, you can immediately tell 'this name is a macro, not a real variable' — a visual cue that partly compensates for the debugger-invisibility problem.

• #define BUFFER_SIZE 256 — a preprocessor directive performing blind textual substitution, handled before compilation. • Macros have no type at all — the compiler cannot type-check their usage the way it can a const variable. • Macros are invisible to a debugger by name; only the substituted literal value ever appears in compiled code. • Convention: macro names are ALL_CAPS, signaling at a glance 'this is a macro, not a real variable'. • Still useful for conditional compilation (#ifdef) and array-sizing contexts requiring a strict compile-time constant.

enum: Named Integer Constants as a Group

An enum (enumeration) declares a set of related named integer constants together, as a group, which is especially useful when a value is really one of a small, fixed set of meaningful options rather than an arbitrary number. enum { DefaultTimeout = 30 }; declares an unnamed enumeration containing a single constant, DefaultTimeout, with the integer value 30. More commonly, you list several related constants together: enum { StatusPending = 0, StatusActive = 1, StatusClosed = 2 }; — and if you don't explicitly assign a value to each one, C automatically assigns them sequential integers starting from 0, so enum { StatusPending, StatusActive, StatusClosed }; means exactly the same thing.

Like macros, the individual constants inside an enum are compile-time integer constants with no storage of their own at runtime — the value is substituted at compile time, similar in spirit to #define, but the mechanism is entirely different: enum constants are handled by the compiler itself (as real, grammatically recognized symbols with an actual, if generic, integer type), not by the preprocessor's blind text substitution. This means, unlike macros, enum constant names are visible to a debugger and participate in normal C scoping rules, giving them a real type identity the way const variables have, even though they don't occupy their own dedicated memory the way a const variable typically does.

Enums are the natural choice specifically when you're modeling 'one of several related, named options' — a set of statuses, directions, error codes, or configuration modes — because grouping them under one enum communicates that relationship directly in the code, and because giving each option a name (rather than a bare number sprinkled through your code) makes the code dramatically more readable: if (status == StatusActive) is unambiguous in a way if (status == 1) never quite is. Objective-C leans on this pattern constantly — many of Apple's own frameworks expose configuration options and states as enum-based constants, so getting comfortable naming and reasoning about small closed sets of options here in C pays off directly and immediately.

• enum { DefaultTimeout = 30 }; — declares a named, compile-time integer constant grouped with (optionally) related others. • Without explicit values, C assigns sequential integers automatically starting from 0. • Enum constants are recognized by the compiler as real symbols (visible to a debugger), unlike preprocessor macros. • Best used for a small, fixed, related set of named options (statuses, modes, directions) rather than one arbitrary number. • Objective-C and Apple's frameworks use this exact enum-based-constants pattern pervasively for configuration and state values.

Choosing Between const, #define, and enum

For a single, standalone constant value of a specific type — a maximum count, a tax rate, a default timeout — modern C style strongly favors const over #define, precisely because of the advantages already covered: real type checking, real scope rules, and real debugger visibility. A const variable behaves exactly like every other variable you already understand, just with reassignment forbidden, which makes it the least surprising, easiest-to-reason-about option in the vast majority of everyday cases.

#define earns its keep specifically for conditional compilation and for the rarer cases where you genuinely need a raw compile-time constant expression outside the type system (such as certain array-size declarations in strict, older C code, or configuration switches like #define DEBUG 1 combined with #ifdef DEBUG). If you don't have one of those specific needs, reaching for const first is the safer default, because it gives the compiler far more information to catch mistakes with.

enum is the right tool specifically when you have a small, closed, related set of named options rather than a single independent value — grouping StatusPending, StatusActive, and StatusClosed together under one enum communicates that these three names are meaningfully related alternatives to each other, something neither a handful of separate const ints nor a handful of separate #defines expresses as directly. As a rule of thumb for this course going forward: reach for const by default for a single fixed value, reach for enum when you have a related group of named integer options, and reserve #define for conditional compilation or the rare cases requiring a raw preprocessor-level constant.

• Default choice for a single fixed value of a specific type: const (type-checked, debugger-visible, scoped normally). • Reach for #define mainly for conditional compilation (#ifdef) or contexts requiring a raw compile-time constant expression. • Reach for enum when you have a small, related group of named integer options, not just one isolated value. • All three make a value's intent unchangeable, but only const gives full compiler type-checking; #define gives none; enum gives compiler-level symbol recognition without a distinct type.

Code Examples

All three constants end up usable the same way in ordinary expressions, but they arrive differently: BUFFER_SIZE is textually replaced with 256 before compilation ever starts, maxUsers is a real int variable the compiler will refuse to let you reassign, and DefaultTimeout/MaxRetries are compiler-recognized integer constants grouped together in one enum. Expected result: Max users: 100 Buffer size: 256 Timeout: 30, Retries: 3

Attempting to assign a new value to a const variable after its initialization is a compile-time error -- the compiler refuses to build this program at all, which is exactly the safety const is designed to provide. Compare this to a plain (non-const) int, where the same reassignment would compile without complaint. Expected result: error: cannot assign to variable 'maxUsers' with const-qualified type 'const int'

Common mistake: Trying to 'fix' this by removing const defeats the purpose -- const exists precisely so accidental reassignments like this are caught before the program ever runs.

Because none of the enum members were given an explicit value, C automatically numbers them sequentially starting from 0: StatusPending is 0, StatusActive is 1, StatusClosed is 2. Comparing against the named constant StatusActive is far more readable than comparing against the bare literal 1. Expected result: Account is active (value 1) const int MAX_USERS = 100; // a real, type-checked constant with normal scope

Because #define performs blind text substitution with no real scoping, a macro name can silently collide with or corrupt unrelated code anywhere it's textually reused, producing confusing errors far from the actual #define line. const variables follow normal, predictable C scope rules instead.

const int retries = 3; retries++; // attempting to increment a const variable int retries = 3; // use a plain (non-const) variable if the value must change retries++;

The ++ operator is still an assignment under the hood, and a const-qualified variable forbids any assignment after initialization, including increment/decrement -- the compiler will reject this exactly as it would a plain assignment.

enum { StatusPending, StatusActive, StatusClosed }; if (accountStatus == 1) { /* which status is 1, again? */ } enum { StatusPending, StatusActive, StatusClosed }; if (accountStatus == StatusActive) { /* clearly means Active */ }

Comparing against the bare numeric literal defeats the readability purpose of declaring an enum in the first place; always compare against the named constant so the intent is clear at the point of use, not just at the enum's declaration.

Why can a debugger show the name and current value of a const variable, but not of a #define macro? A. Debuggers simply don't support macros as a feature limitation with no real reason B. A const variable is a real, typed symbol the compiler keeps around; a macro is purely textual substitution completed before compilation, so the macro name no longer exists once the compiler runs C. #define is actually faster so debuggers skip it intentionally D. const variables are always global, which is why debuggers can see them

B. A const variable is a real, typed symbol the compiler keeps around; a macro is purely textual substitution completed before compilation, so the macro name no longer exists once the compiler runs — const declares a real variable with a type that the compiler retains as a symbol; #define is resolved entirely during preprocessing via text substitution, so by the time the compiler (and later the debugger) sees the code, the macro name is already gone, replaced by its literal value.

What happens if you write const int retries = 3; and later try retries = 5; in the same scope? A. It works fine; const only prevents changes to the variable's type, not its value B. It's a compile-time error, because const marks the variable as read-only after initialization C. It works, but only prints a runtime warning D. It silently does nothing and retries stays 3

B. It's a compile-time error, because const marks the variable as read-only after initialization — const is enforced by the compiler at compile time: any attempt to assign a new value to a const-qualified variable after its initial value is set is rejected before the program can even be built.

If no explicit values are given, what values does C automatically assign to the members of enum { A, B, C };? A. A=1, B=2, C=3 B. Random unique values C. A=0, B=1, C=2, counting sequentially from 0 D. All three are given the same value, 0

C. A=0, B=1, C=2, counting sequentially from 0 — Unless you assign explicit values to enum members, C numbers them automatically in sequence starting from 0, so the first member is 0, the second is 1, and so on.

Which situation is the best fit for #define rather than const or enum? A. Declaring a single fixed maximum user count for use throughout a file B. Grouping three related named statuses (Pending, Active, Closed) together C. Conditionally compiling debug-only code, e.g. #ifdef DEBUG ... #endif D. Storing a temperature value that should never change after being read from a sensor

C. Conditionally compiling debug-only code, e.g. #ifdef DEBUG ... #endif — Conditional compilation directives like #ifdef specifically require the preprocessor-level mechanism #define provides; a single fixed value is better served by const (type safety, debugger visibility) and a related group of named options is better served by enum.

#include <stdio.h>

#define BUFFER_SIZE 256          // preprocessor macro: pure text substitution

const int maxUsers = 100;         // const: real, type-checked, read-only variable

enum {
    DefaultTimeout = 30,           // enum: named compile-time integer constant
    MaxRetries = 3
};

int main(void) {
    char buffer[BUFFER_SIZE];      // BUFFER_SIZE is replaced with 256 here, at preprocess time

    printf("Max users: %d\n", maxUsers);
    printf("Buffer size: %d\n", BUFFER_SIZE);
    printf("Timeout: %d, Retries: %d\n", DefaultTimeout, MaxRetries);

    return 0;
}
#include <stdio.h>

int main(void) {
    const int maxUsers = 100;
    printf("Max users: %d\n", maxUsers);

    maxUsers = 150;   // compile-time error: assignment of read-only variable

    return 0;
}
#include <stdio.h>

enum {
    StatusPending,   // 0, since no value given
    StatusActive,     // 1
    StatusClosed      // 2
};

int main(void) {
    int status = StatusActive;

    if (status == StatusActive) {
        printf("Account is active (value %d)\n", status);
    }

    return 0;
}