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](/en/blog/cpp-comparison-04-shared-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("[email protected]", "Hello");
sendEmail("[email protected]", "Hi", "[email protected]");
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
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. std::optional vs nullptr: optional models absent values with type safety; pointers for non-owning observers, polymorphis… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- [C++ shared_ptr vs unique_ptr: Smart Pointer Choice Complete](/en/blog/cpp-comparison-04-shared-unique-ptr/
- [C++ std::variant vs union Complete Comparison](/en/blog/cpp-comparison-13-variant-union/
- [C++ std::any vs void* Complete Comparison](/en/blog/cpp-comparison-14-any-void-pointer/
이 글에서 다루는 키워드 (관련 검색어)
C++, optional, pointer, C++17, null safety, type safety 등으로 검색하시면 이 글이 도움이 됩니다.