C++ std::optional vs Pointers: Representing No Value
이 글의 핵심
std::optional vs nullptr: optional models absent values with type safety; pointers for non-owning observers, polymorphism, and shared ownership. Stack-friendly optional vs pointer costs.
When you need owning heap storage instead of optional, compare shared_ptr vs unique_ptr.
Introduction: “How should I represent null?”
C++ offers nullptr pointers and std::optional
This article covers:
- optional vs pointers
- Type safety
- Performance
- Scenarios
1. Comparison
| Aspect | std::optional | T* |
|---|---|---|
| Storage | Usually inline (stack) | Address-sized (points elsewhere) |
| Ownership model | Owns value state | Aliases external object |
| Absent value | std::nullopt | nullptr |
| Safety | Explicit checks required | Easy to forget null check |
| Heap allocation | No (unless T itself allocates) | Depends on what it points to |
| Size | sizeof(T) + 1 (approx) | sizeof(void*) |
2. Type safety
std::optional forces explicit handling
#include <optional>
#include <string>
std::optional<std::string> findUser(int id) {
if (id == 1) {
return "Alice";
}
return std::nullopt; // Explicit "no value"
}
// Usage
auto user = findUser(1);
if (user.has_value()) {
std::cout << *user << "\n"; // Safe access
}
// Or with value_or
std::string name = findUser(2).value_or("Guest");
Pointers allow silent null dereference
std::string* findUserPtr(int id) {
static std::string alice = "Alice";
if (id == 1) {
return &alice;
}
return nullptr;
}
// ❌ Easy to forget null check
auto* user = findUserPtr(2);
std::cout << *user << "\n"; // Crash!
// ✅ Must remember to check
if (user != nullptr) {
std::cout << *user << "\n";
}
3. Performance
Memory layout
#include <optional>
#include <iostream>
struct Small {
int value;
};
struct Large {
char data[1000];
};
int main() {
std::cout << "int: " << sizeof(int) << "\n"; // 4
std::cout << "optional<int>: " << sizeof(std::optional<int>) << "\n"; // 8
std::cout << "int*: " << sizeof(int*) << "\n"; // 8
std::cout << "Large: " << sizeof(Large) << "\n"; // 1000
std::cout << "optional<Large>: " << sizeof(std::optional<Large>) << "\n"; // ~1008
std::cout << "Large*: " << sizeof(Large*) << "\n"; // 8
}
Benchmark (GCC 13, -O3, 1M operations)
| Operation | optional | int* (stack) | int* (heap) |
|---|---|---|---|
| Create | 2ms | 2ms | 450ms |
| Check + access | 3ms | 3ms | 3ms |
| Destroy | 0ms | 0ms | 420ms |
Key insight: optional avoids heap allocation for small types. For large types, pointer indirection may be better.
4. Real-world scenarios
Scenario 1: Optional return values
#include <optional>
#include <string>
#include <map>
class UserDatabase {
std::map<int, std::string> users_;
public:
std::optional<std::string> findUser(int id) const {
auto it = users_.find(id);
if (it != users_.end()) {
return it->second;
}
return std::nullopt;
}
// Alternative: pointer version (less safe)
const std::string* findUserPtr(int id) const {
auto it = users_.find(id);
return (it != users_.end()) ? &it->second : nullptr;
}
};
// Usage comparison
UserDatabase db;
// optional: explicit handling
if (auto user = db.findUser(1)) {
std::cout << *user << "\n";
}
// pointer: easy to forget check
auto* user = db.findUserPtr(1);
if (user) { // Must remember!
std::cout << *user << "\n";
}
Scenario 2: Optional function parameters
#include <optional>
#include <string>
void sendEmail(const std::string& to,
const std::string& subject,
std::optional<std::string> cc = std::nullopt) {
std::cout << "To: " << to << "\n";
std::cout << "Subject: " << subject << "\n";
if (cc) {
std::cout << "CC: " << *cc << "\n";
}
}
// Usage
sendEmail("alice@example.com", "Hello");
sendEmail("bob@example.com", "Hi", "charlie@example.com");
Scenario 3: Lazy initialization
class ExpensiveResource {
mutable std::optional<std::string> cache_;
public:
const std::string& getData() const {
if (!cache_) {
cache_ = computeExpensiveData(); // Lazy init
}
return *cache_;
}
private:
std::string computeExpensiveData() const {
// Expensive computation
return "computed data";
}
};
Scenario 4: Polymorphism requires pointers
class Base {
public:
virtual void process() = 0;
virtual ~Base() = default;
};
class Derived : public Base {
public:
void process() override { std::cout << "Derived\n"; }
};
// ❌ Cannot use optional for polymorphism
// std::optional<Base> obj; // Error: Base is abstract
// ✅ Must use pointer
std::unique_ptr<Base> obj = std::make_unique<Derived>();
obj->process();
When to use each
Use std::optional when:
-
Return value may be absent: Parsing, lookups, validation
std::optional<int> parseInt(const std::string& s); -
Optional struct members: Avoid sentinel values
// 타입 정의
struct Config {
std::string host;
std::optional<int> port; // May not be specified
};
- Avoiding heap allocation: Small types that don’t need indirection
std::optional<int> cachedValue; // Stack storage
Use pointers when:
-
Polymorphism: Virtual dispatch
std::unique_ptr<Base> obj; -
Large objects: Avoid copying
void process(const LargeObject* obj); // Non-owning -
Array/buffer access: Pointing into existing memory
int* begin = array; int* end = array + size; -
Shared ownership: Multiple owners
std::shared_ptr<Resource> shared;
Common mistakes
Mistake 1: Dereferencing without checking
std::optional<int> opt = std::nullopt;
// ❌ Undefined behavior
int x = *opt;
// ✅ Check first
if (opt) {
int x = *opt;
}
// ✅ Or use value_or
int x = opt.value_or(0);
// ✅ Or use value() with exception
try {
int x = opt.value(); // Throws std::bad_optional_access
} catch (const std::bad_optional_access&) {
// Handle
}
Mistake 2: Dangling pointer from optional
std::optional<std::string> getString() {
return "hello";
}
// ❌ Dangling pointer
const char* ptr = getString()->c_str(); // Temporary destroyed!
// ✅ Store optional first
auto opt = getString();
if (opt) {
const char* ptr = opt->c_str(); // Safe
}
Mistake 3: Using optional for large objects
struct HugeData {
char buffer[10000];
};
// ❌ Wastes stack space
std::optional<HugeData> opt; // ~10KB on stack even when empty
// ✅ Better: use unique_ptr
std::unique_ptr<HugeData> ptr; // 8 bytes, heap when needed
Advanced patterns
Optional chaining (monadic operations C++23)
// C++23: transform, and_then, or_else
std::optional<int> opt = 42;
auto result = opt
.transform([](int x) { return x * 2; })
.and_then([](int x) -> std::optional<int> {
return x > 50 ? std::optional(x) : std::nullopt;
})
.or_else([] { return std::optional(0); });
// C++17 manual equivalent
std::optional<int> result;
if (opt) {
int doubled = *opt * 2;
if (doubled > 50) {
result = doubled;
}
}
if (!result) {
result = 0;
}
Optional reference wrapper
#include <optional>
#include <functional>
// optional cannot hold references directly
// std::optional<int&> ref; // ❌ Error
// ✅ Use reference_wrapper
std::optional<std::reference_wrapper<int>> ref;
int x = 10;
ref = std::ref(x);
if (ref) {
ref->get() = 20; // Modifies x
}
Compiler support
| Compiler | std::optional | Monadic operations |
|---|---|---|
| GCC | 7+ | 12+ (C++23) |
| Clang | 4+ | 16+ (C++23) |
| MSVC | 2017 15.3+ | 2022 17.4+ (C++23) |
Related posts
Keywords
std::optional, optional vs pointer, C++17, null safety, type safety, value semantics, nullptr
Frequently Asked Questions (FAQ)
Q. When would I use this in practice?
A. std::optional vs nullptr: optional models absent values with type safety; pointers for non-owning observers, polymorphis…
Q. What should I read before this?
A. Follow the previous article or related articles links at the bottom of each post to learn in sequence. See the C++ series index for the full picture.
Q. Where can I study this more deeply?
A. Check cppreference and the relevant library’s official documentation. The reference links at the end of the article are also worth using.
Related Articles (Internal Links)
Other articles related to this topic.
- C++ shared_ptr vs unique_ptr: Smart Pointer Choice Complete
- C++ std::variant vs union Complete Comparison
- C++ std::any vs void* Complete Comparison
Keywords Covered in This Article (Related Search Terms)
This article covers C++, optional, pointer, C++17, null safety, type safety.