C++ / REFERENCES, POINTERS, AND NULL
nullptr and why NULL and zero are retired
Spell null pointers with nullptr, explain why NULL and 0 misfire in overload resolution and templates, and use std::nullptr_t in your own APIs.
What you will learn
- Write nullptr in initializers, returns, and comparisons instead of NULL or 0
- Predict which overload 0, NULL, and nullptr each select, and explain why
- Keep nullness across template deduction, since nullptr deduces as std::nullptr_t
- Ban null arguments at compile time with a deleted std::nullptr_t overload
Understanding nullptr and why NULL and zero are retired
C++ inherited its null pointer from C, where the null pointer constant is written as the integer literal 0, and NULL is a macro that expands to 0, 0L, or a compiler-specific token such as __null. A zero-valued integral constant expression is allowed to convert to any pointer type, which is why char* p = 0; works at all. The price is that one token carries two meanings and only the surrounding context tells them apart. When the context is a set of overloads, or a template parameter still waiting to be deduced, that context does not exist yet, and the compiler falls back on what the token literally is: an integer.
nullptr is a keyword rather than a macro, and its type is std::nullptr_t. That type converts implicitly to every pointer type and every pointer-to-member type, and to bool in direct-initialization contexts, but there is no conversion from it to int, long, or any other arithmetic type. The mental model is that 0 is a number the compiler will reshape into a pointer when it can see the destination, while nullptr is a pointer-shaped nothing that never had a numeric identity to lose. That is why, given void f(int) and void f(char*), the call f(nullptr) picks the pointer version and f(0) picks the int version.
The distinction matters most when the value travels. Deduce a template parameter from 0 and you get T = int; the parameter inside the template is an ordinary int object, not a null pointer constant, so it no longer converts to a pointer and the code stops compiling. Deduce from nullptr and T is std::nullptr_t, so the nullness survives any number of forwarding layers. None of this changes runtime behaviour: nullptr yields exactly the null pointer value 0 always yielded, dereferencing it is still undefined behaviour, and its bit pattern is still not guaranteed to be all zeros.
<iostream>
void take(int n) { std::cout << "take(int): " << n << '\n'; }
void take(const char* s) { std::cout << "take(const char*): " << (s ? s : "null pointer") << '\n'; }
int main() {
take(0); // exact match on int; the literal never becomes a pointer here
take(nullptr); // only the pointer overload is viable
const char* greeting = "hi";
take(greeting);
int* p = nullptr;
std::cout << "p is " << (p == nullptr ? "null" : "not null") << '\n';
// int k = nullptr; // error: no conversion from std::nullptr_t to int
// take(NULL); // picks take(int) or is ambiguous, depending on how NULL is defined
}
nullptr is a value of its own type, std::nullptr_t, that only pointers accept, so "no object" stops sharing a spelling with the integer zero.
Worked examples
Nullness surviving template deduction
Shows that 0 deduces as int and loses its pointer meaning, while nullptr deduces as std::nullptr_t and keeps it.
<cstddef>
<iostream>
<type_traits>
void probe(int) { std::cout << "probe(int)\n"; }
void probe(void*) { std::cout << "probe(void*)\n"; }
template <typename T>
void relay(T value) {
std::cout << "deduced nullptr_t: " << std::boolalpha
<< std::is_same<T, std::nullptr_t>::value << ", calls ";
probe(value);
}
int main() {
relay(0);
relay(nullptr);
}
Example explained
Line 1relay(0) deduces T = int, so probe(value) is an exact match for probe(int) and the zero has no remaining connection to pointers.
Line 2relay(nullptr) deduces T = std::nullptr_t, a real type that is preserved by the copy into the parameter.
Line 3For a std::nullptr_t argument, probe(int) is not even a candidate, because no conversion from std::nullptr_t to an integer exists.
Line 4Replace probe(void*) with an overload taking only char* and relay(0) fails to compile: an int object is not a null pointer constant, unlike the literal 0.
Rejecting null at compile time
Uses a deleted std::nullptr_t overload to make passing the literal nullptr a compile error.
<cstddef>
<cstring>
<iostream>
std::size_t labelLength(const char* label) {
return std::strlen(label); // caller must pass a real string
}
std::size_t labelLength(std::nullptr_t) = delete;
int main() {
const char* text = "engine";
std::cout << labelLength(text) << '\n';
std::cout << labelLength("ok") << '\n';
// std::cout << labelLength(nullptr); // error: use of deleted function
const char* maybe = nullptr;
if (maybe == nullptr) {
std::cout << "maybe is null, so we skip the call\n";
}
}
Example explained
Line 1labelLength(std::nullptr_t) = delete adds an exact-match candidate, so labelLength(nullptr) selects it and the compiler refuses instead of converting nullptr to const char*.
Line 2labelLength("ok") never considers the deleted overload, because a const char[3] has no conversion to std::nullptr_t.
Line 3std::strlen(nullptr) is undefined behaviour, which is what makes the extra declaration worth writing.
Line 4The deleted overload only catches nulls visible at compile time; maybe is a runtime null, so the explicit check is still required.
Important notes
NULL is still valid C++ and still appears in C headers; <cstddef> defines it, and implementations expand it to 0, 0L, or a builtin such as __null, which is why f(NULL) may pick the int overload on one compiler and be ambiguous on another.
nullptr says nothing about the bit pattern of a null pointer, which is not required to be all zeros; compare with p == nullptr or if (p) rather than memcmp or a cast to an integer type.
Common mistakes
Writing int count = NULL; because NULL "means empty". It compiles, since NULL is an integer constant, so a type confusion silently becomes the value 0 and survives review.
Assuming nullptr makes pointers safe: int* p = nullptr; *p = 1; is undefined behaviour and usually a segfault. nullptr only fixes how you spell the value.
Migrating declarations but leaving calls like f(0) and checks like p != 0 in place. Those still compile and still pick integer overloads, so the resolution bug the migration was meant to remove remains.
Try it yourself
Change, predict, then run
In a browser editor, write void report(int) and void report(const std::string*) that each print which one ran, then call report with 0, with nullptr, and with the address of a std::string, predicting each result first. Then swap nullptr for NULL and record whether your compiler picks the int overload or reports an ambiguity.
Open the C++ workspaceCheck your understanding
sink is declared only as void sink(char*). The call sink(0) compiles at file scope, but relay(0) does not compile given template <class T> void relay(T v) { sink(v); }. Why?
- Inside relay, v is an ordinary int object rather than the literal 0, and only a null pointer constant converts to char*
- Function templates suppress implicit conversions on their parameters, so no argument conversion is attempted
- 0 deduces as std::nullptr_t, and std::nullptr_t has no conversion to char*
- The call is ambiguous, because both int and char* are viable targets for the value zero
Show answer
The literal 0 is a null pointer constant, so the compiler may convert it to char* at the direct call. Deduction gives T = int, and the parameter inside relay is just an int object; the null-pointer-constant conversion applies to constants, not to arbitrary int values, so nothing turns it into char*. Option 3 inverts the rule: nullptr is what deduces as std::nullptr_t, and std::nullptr_t does convert to char*, which is exactly why relay(nullptr) compiles while relay(0) does not.