C++ Module 7 – Classes, objects and RAII
Designing value types and strong types in C++
Design C++ types that behave like int: independent copies, checked construction so invalid values cannot exist, and strong types that stop unit mix-ups.
What you will learn
- Design types that behave like built-in values, with independent copies and meaningful equality
- Prevent invalid states with a checked factory that returns std::expected or std::optional
- Use strong types to stop unit mix-ups without using extra memory
- Choose where [[nodiscard]], noexcept and immutability belong in a value type
Before you start
What you’ll learn · Before you start
What you will learn
- Design types that behave like built-in values, with independent copies and meaningful equality
- Prevent invalid states with a checked factory that returns std::expected or std::optional
- Use strong types to stop unit mix-ups without using extra memory
- Choose where [[nodiscard]], noexcept and immutability belong in a value type
Before you start
On this page
A value type behaves like int. A copy is a separate value, so changing it never changes the original; two
objects that hold the same value compare equal; and nothing is shared behind the scenes. Good value types also
make invalid states unrepresentable: the only way to create one checks its value first, for example a factory
function that returns std::expected<Date, std::string>, so no impossible date can ever exist and no function has
to check again. Strong types apply the same idea to plain numbers. When Paise and Rupees are different
types rather than two names for std::int64_t, adding rupees to paise is a compile error instead of a wrong total.
This lesson shows a copy that secretly shares its data, builds a date that cannot be invalid, turns a unit bug into
a compile error at no run-time cost, and settles where [[nodiscard]], noexcept and const belong. Every example
runs with Apple clang 21 and GCC 16.
A value type behaves like int
After int b = a; b += 1; nobody expects a to change. A class that holds its data through a pointer can break that
expectation without anyone noticing, because copying the class copies only the pointer:
// Two types that look alike: copies of one share their data, copies of the other are independent values.
#include <cstddef>
#include <memory>
#include <print>
#include <vector>
class SharedMarks { // copying it copies a pointer, so copies share one vector
public:
void add(int m) { data_->push_back(m); }
std::size_t count() const { return data_->size(); }
private:
std::shared_ptr<std::vector<int>> data_ = std::make_shared<std::vector<int>>();
};
class Marks { // a value type: copying it copies the marks
public:
void add(int m) { data_.push_back(m); }
std::size_t count() const { return data_.size(); }
bool operator==(const Marks&) const = default;
private:
std::vector<int> data_;
};
int main() {
SharedMarks a;
a.add(91);
SharedMarks b = a;
b.add(72);
std::println("SharedMarks: a has {}, b has {}", a.count(), b.count());
Marks x;
x.add(91);
Marks y = x;
y.add(72);
std::println("Marks: x has {}, y has {}, x == y is {}", x.count(), y.count(), x == y);
y = x;
std::println("after y = x: x == y is {}", x == y);
} from session import run
# shared_state.cpp built and run with Apple clang, then with GCC 16.
run("""
clang++ -std=c++23 -Wall -Wextra shared_state.cpp -o shared_state && ./shared_state
g++-16 -std=c++23 -Wall -Wextra shared_state.cpp -o shared_state-gcc && ./shared_state-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra shared_state.cpp -o shared_state && ./shared_state SharedMarks: a has 2, b has 2 Marks: x has 1, y has 2, x == y is false after y = x: x == y is true $ g++-16 -std=c++23 -Wall -Wextra shared_state.cpp -o shared_state-gcc && ./shared_state-gcc SharedMarks: a has 2, b has 2 Marks: x has 1, y has 2, x == y is false after y = x: x == y is true
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 value_session.py
SharedMarks keeps its marks in a std::shared_ptr<std::vector<int>>, so b = a makes both objects point to one
vector, and adding a mark to b adds it to a as well: each reports 2. Marks holds the std::vector itself.
Copying it copies the marks, so y gets its own list, the two differ, and after y = x they compare equal again.
The C++ Core Guidelines call such types regular (rule C.11): they can be default-constructed, copied and
compared with ==, and a copy compares equal to its original until one of them changes. That is the behaviour the
standard containers and algorithms assume. Sharing has its uses, such as a large immutable table read by many
objects, but then the type should say so in its name and documentation, not surprise the reader of b = a.
Make invalid values impossible to create
April has 30 days, so a Date for day 31 of month 4 must never exist. If any code could build a Date from three
integers, every function that receives a Date would have to check it. Instead, make the constructor private and
offer one way in that checks:
// A date that cannot hold an impossible day: the only way to make one checks it first.
#include <array>
#include <chrono>
#include <compare>
#include <expected>
#include <format>
#include <print>
#include <string>
class Date {
public:
// The factory checks the calendar and returns either a Date or the reason there is none.
static std::expected<Date, std::string> make(int y, int m, int d) {
std::chrono::year_month_day ymd{std::chrono::year{y}, std::chrono::month(static_cast<unsigned>(m)),
std::chrono::day(static_cast<unsigned>(d))};
if (m < 1 || d < 1 || !ymd.ok()) return std::unexpected(std::format("{}-{:02}-{:02} is not a date", y, m, d));
return Date(y, m, d);
}
int year() const noexcept { return y_; }
int month() const noexcept { return m_; }
int day() const noexcept { return d_; }
// Operations return new dates; they never change this one, and every result is valid again.
[[nodiscard]] Date add_days(int n) const {
std::chrono::year_month_day r{std::chrono::sys_days{ymd()} + std::chrono::days{n}};
return Date(static_cast<int>(r.year()), static_cast<int>(static_cast<unsigned>(r.month())), static_cast<int>(static_cast<unsigned>(r.day())));
}
[[nodiscard]] int days_until(const Date& other) const {
return (std::chrono::sys_days{other.ymd()} - std::chrono::sys_days{ymd()}).count();
}
// Year, then month, then day: member order is the calendar's order.
auto operator<=>(const Date&) const = default;
private:
Date(int y, int m, int d) : y_(y), m_(m), d_(d) {} // private: callers must go through make()
std::chrono::year_month_day ymd() const {
return {std::chrono::year{y_}, std::chrono::month(static_cast<unsigned>(m_)), std::chrono::day(static_cast<unsigned>(d_))};
}
int y_;
int m_;
int d_;
};
std::string text(const Date& d) { return std::format("{}-{:02}-{:02}", d.year(), d.month(), d.day()); }
int main() {
for (auto [y, m, d] : {std::array{2024, 2, 29}, std::array{2023, 2, 29}, std::array{2026, 13, 1}, std::array{2026, 4, 31}}) {
std::expected<Date, std::string> date = Date::make(y, m, d);
if (date) std::println("{} is a date", text(*date));
else std::println("{}", date.error());
}
Date start = *Date::make(2025, 12, 20); // the * is safe here: these two dates are known to be valid
Date exam = *Date::make(2026, 3, 2);
std::println("{} days from {} to {}", start.days_until(exam), text(start), text(exam));
std::println("100 days after {} is {}", text(start), text(start.add_days(100)));
std::println("start < exam is {}", start < exam);
} from session import run
# date.cpp built and run with both compilers.
run("""
clang++ -std=c++23 -Wall -Wextra date.cpp -o date && ./date
g++-16 -std=c++23 -Wall -Wextra date.cpp -o date-gcc && ./date-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra date.cpp -o date && ./date 2024-02-29 is a date 2023-02-29 is not a date 2026-13-01 is not a date 2026-04-31 is not a date 72 days from 2025-12-20 to 2026-03-02 100 days after 2025-12-20 is 2026-03-30 start < exam is true $ g++-16 -std=c++23 -Wall -Wextra date.cpp -o date-gcc && ./date-gcc 2024-02-29 is a date 2023-02-29 is not a date 2026-13-01 is not a date 2026-04-31 is not a date 72 days from 2025-12-20 to 2026-03-02 100 days after 2025-12-20 is 2026-03-30 start < exam is true
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 date_session.py
One checked way in, so that every Date is valid
Text description of the diagram
The diagram shows how the Date class keeps its invariant, the rule that every Date object is a real day of the calendar.
- Numbers for a year, a month and a day can come from anywhere, for example 2023, 2 and 29.
- They must go through Date::make, because the constructor of Date is private. make is the only way to create a Date.
- When the numbers are not a real day, make returns std::unexpected with a message that says so, and no Date exists.
- When they are a real day, make returns a Date. From then on, add_days, days_until and the comparison operators never check the date again, and every Date they return is valid too.
Date::make asks the standard library’s calendar, std::chrono::year_month_day, whether the numbers are a real day.
It returns std::expected<Date, std::string> (C++23): either a Date or the reason there is none. 2024-02-29
passes, while 2023-02-29, 2026-13-01 and 2026-04-31 come back as errors with their reason. From then on the
class invariant holds: every Date that exists is a real day, so add_days, days_until and the comparisons
can rely on it instead of checking again.
Three ways to report an invalid value are common, and each suits a different caller:
- A constructor that throws gives the caller an object or an exception that carries the reason, and the failure cannot be ignored by accident, because the exception propagates. It suits values that are wrong only because of a bug, or truly exceptional.
- A factory returning
std::optionalgives a value or nothing, so the reason for a failure is lost. It suits a check whose only failure is “not valid”. - A factory returning
std::expectedgives a value or an error value of any type you choose, so the reason is kept. It suits input from users or files, where failure is normal.
With both factories a careless caller can still write *result without checking, which is undefined behaviour on a
failure, so check before you dereference, as the loop in main does with if (date).
Guideline C.42 says a constructor that cannot create a valid object should throw. That fits values that are wrong
only because of a bug. Input typed by a person is wrong all the time, which is why Date::make returns an
std::expected instead.
Comparisons come almost for free here. The members are declared year, month, day, so the defaulted <=> of the
operator lesson compares them in the calendar’s order, and start < exam means “earlier than”.
Operations that return new values
add_days does not move a date; it returns another one, as x + 1 returns a new number and leaves x alone.
That design makes one mistake easy, calling the function and dropping its result, and [[nodiscard]] turns that
mistake into a warning:
// add_days returns a new Date and leaves the old one alone, so ignoring its result is almost always a bug.
#include <chrono>
class Date {
public:
explicit Date(std::chrono::sys_days d) : d_(d) {}
[[nodiscard]] Date add_days(int n) const { return Date(d_ + std::chrono::days{n}); }
private:
std::chrono::sys_days d_;
};
int main() {
Date start(std::chrono::sys_days{std::chrono::year{2025} / 12 / 20});
start.add_days(30); // meant to move start forward a month; it changes nothing
} from session import run
# nodiscard_warning.cpp compiles, with a warning from each compiler.
run("""
clang++ -std=c++23 -Wall -Wextra -fsyntax-only nodiscard_warning.cpp
g++-16 -std=c++23 -Wall -Wextra -fsyntax-only nodiscard_warning.cpp
""") Output
$ clang++ -std=c++23 -Wall -Wextra -fsyntax-only nodiscard_warning.cpp
nodiscard_warning.cpp:15:5: warning: ignoring return value of function declared with 'nodiscard' attribute [-Wunused-result]
15 | start.add_days(30); // meant to move start forward a month; it changes nothing
| ^~~~~~~~~~~~~~ ~~
1 warning generated.
$ g++-16 -std=c++23 -Wall -Wextra -fsyntax-only nodiscard_warning.cpp
nodiscard_warning.cpp: In function 'int main()':
nodiscard_warning.cpp:15:19: warning: ignoring return value of 'Date Date::add_days(int) const', declared with attribute 'nodiscard' [-Wunused-result]
15 | start.add_days(30); // meant to move start forward a month; it changes nothing
| ~~~~~~~~~~~~~~^~~~
nodiscard_warning.cpp:7:24: note: declared here
7 | [[nodiscard]] Date add_days(int n) const { return Date(d_ + std::chrono::days{n}); }
| ^~~~~~~~
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 nodiscard_session.py
Mark every function whose only effect is its return value [[nodiscard]]: queries, factories such as make and
operations such as add_days. Mark functions noexcept where that is true and will stay true, such as the
accessors year(), month() and day(). noexcept is a promise that callers may rely on, and an exception that
escapes such a function ends the program.
An object whose state never changes after construction is also the easiest kind to share. Guideline Con.1 suggests
making objects immutable by default, and two threads may read the same Date at the same time without any lock,
because a data race needs at least one of them to write. Make a type immutable through its interface, with private
members and no functions that change them, and not with const data members:
// A const data member makes the type impossible to assign, and std::sort needs assignment.
#include <algorithm>
#include <string>
#include <vector>
struct Student {
const int roll; // meant as "never changes", but it also forbids s1 = s2
std::string name;
};
int main() {
std::vector<Student> v{{12, "Asha"}, {7, "Dev"}};
std::ranges::sort(v, {}, &Student::roll); // error: sorting moves elements by assignment
} from session import run
# The first error each compiler reports for const_member.cpp, and how long its whole report is.
run("""
clang++ -std=c++23 -fsyntax-only const_member.cpp 2>&1 | grep -m1 error:
clang++ -std=c++23 -fsyntax-only const_member.cpp 2>&1 | wc -l | tr -d ' '
g++-16 -std=c++23 -fsyntax-only const_member.cpp 2>&1 | grep -m1 error:
g++-16 -std=c++23 -fsyntax-only const_member.cpp 2>&1 | wc -l | tr -d ' '
""") Output
$ clang++ -std=c++23 -fsyntax-only const_member.cpp 2>&1 | grep -m1 error: const_member.cpp:13:5: error: no matching function for call to object of type 'const __sort' $ clang++ -std=c++23 -fsyntax-only const_member.cpp 2>&1 | wc -l | tr -d ' ' 28 $ g++-16 -std=c++23 -fsyntax-only const_member.cpp 2>&1 | grep -m1 error: const_member.cpp:13:22: error: no match for call to '(const std::ranges::__sort_fn) (std::vector<Student>&, <brace-enclosed initializer list>, const int Student::*)' $ g++-16 -std=c++23 -fsyntax-only const_member.cpp 2>&1 | wc -l | tr -d ' ' 94
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 const_session.py
A const member makes the whole type impossible to assign, and sorting moves elements around by assignment, so
std::ranges::sort fails, with a report of 28 lines from Clang and 94 from GCC. Guideline C.12 says the same: no const or reference data
members in a type that should be copyable or movable.
Strong types stop unit mix-ups
using Paise = std::int64_t; gives a new name, not a new type, so the compiler cannot tell paise from rupees. A
strong type wraps the number in a struct of its own, and only the operations you write exist for it:
// The same sum written with type aliases, which mix up units silently, and with strong types, which cannot.
#include <chrono>
#include <cstdint>
#include <print>
namespace aliases {
using Paise = std::int64_t; // only new names for the same type
using Rupees = std::int64_t;
Paise total(Paise price, Rupees delivery) { return price + delivery; } // forgot to convert, and nothing complains
} // namespace aliases
namespace strong {
struct Paise {
std::int64_t value = 0;
friend Paise operator+(Paise a, Paise b) { return Paise{a.value + b.value}; }
};
struct Rupees {
std::int64_t value = 0;
};
constexpr Paise to_paise(Rupees r) { return Paise{r.value * 100}; } // the one place that knows the factor
Paise total(Paise price, Rupees delivery) { return price + to_paise(delivery); }
} // namespace strong
int main() {
std::println("aliases: total = {} paise", aliases::total(49900, 40));
std::println("strong: total = {} paise", strong::total(strong::Paise{49900}, strong::Rupees{40}).value);
std::println("sizeof(strong::Paise) = {}, sizeof(std::int64_t) = {}", sizeof(strong::Paise), sizeof(std::int64_t));
using namespace std::chrono_literals; // the standard library's own strong types
std::chrono::milliseconds wait = 2s + 500ms;
std::println("2s + 500ms = {}", wait);
} // With strong types, adding rupees to paise does not compile.
#include <cstdint>
struct Paise {
std::int64_t value = 0;
friend Paise operator+(Paise a, Paise b) { return Paise{a.value + b.value}; }
};
struct Rupees {
std::int64_t value = 0;
};
Paise total(Paise price, Rupees delivery) { return price + delivery; } // error: no operator+ for Paise and Rupees
int main() { return static_cast<int>(total(Paise{49900}, Rupees{40}).value); } from session import run
# units.cpp with both compilers, then the first error each one reports for units_error.cpp.
run("""
clang++ -std=c++23 -Wall -Wextra units.cpp -o units && ./units
g++-16 -std=c++23 -Wall -Wextra units.cpp -o units-gcc && ./units-gcc
clang++ -std=c++23 -fsyntax-only units_error.cpp 2>&1 | grep -m1 error:
g++-16 -std=c++23 -fsyntax-only units_error.cpp 2>&1 | grep -m1 error:
""") Output
$ clang++ -std=c++23 -Wall -Wextra units.cpp -o units && ./units
aliases: total = 49940 paise
strong: total = 53900 paise
sizeof(strong::Paise) = 8, sizeof(std::int64_t) = 8
2s + 500ms = 2500ms
$ g++-16 -std=c++23 -Wall -Wextra units.cpp -o units-gcc && ./units-gcc
aliases: total = 49940 paise
strong: total = 53900 paise
sizeof(strong::Paise) = 8, sizeof(std::int64_t) = 8
2s + 500ms = 2500ms
$ clang++ -std=c++23 -fsyntax-only units_error.cpp 2>&1 | grep -m1 error:
units_error.cpp:12:58: error: invalid operands to binary expression ('Paise' and 'Rupees')
$ g++-16 -std=c++23 -fsyntax-only units_error.cpp 2>&1 | grep -m1 error:
units_error.cpp:12:58: error: no match for 'operator+' (operand types are 'Paise' and 'Rupees')
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 units_session.py
The alias version adds 40 rupees of delivery to 49,900 paise as if both were paise and prints 49,940 instead of
53,900: a bill short by 39.60 rupees, with no warning anywhere. In the strong version the conversion lives in one
function, to_paise, and units_error.cpp shows that forgetting it no longer compiles. The wrapper takes no extra
space: a strong::Paise is 8 bytes, exactly like the integer inside it. Guideline I.4 makes the general point: make
interfaces precisely and strongly typed.
The standard library already uses strong types for time. 2s + 500ms adds seconds to milliseconds and gives
2500ms, because std::chrono converts exactly when it can; turning milliseconds into seconds would lose
precision, so it requires an explicit std::chrono::duration_cast. So there are three ways to give a number a unit:
- A type alias such as
using Paise = std::int64_t;is only a second name, so units mix silently. It is good for shorter names and nothing more. - A strong type such as
struct Paiseis a new type, so mixing units is a compile error, and it takes no extra memory. Use it for money, ids and the measurements of your own domain. std::chrono::durationis one strong type per unit of time, which converts exactly when nothing is lost and only on request otherwise. Use it for time instead of writing your own.
Your turn: a date without the standard calendar
date.h declares a Date like the one above, but without <chrono>: you write the calendar yourself. Complete the
four functions marked TODO:
is_leap(y): a year divisible by 4 is a leap year, except a century year, which must be divisible by 400. So2000was a leap year and2100will not be.make(y, m, d): return the date only when the month is 1 to 12 and the day fits that month; February has 29 days in a leap year. This version returnsstd::optional<Date>, because the only failure is “not a date”.add_days(n): the datendays later, for negativentoo.days_until(other): the number of days from this date toother.
C header · date.h
// Starter code for "Your turn": a calendar date without <chrono>. Complete the functions marked TODO.
#pragma once
#include <compare>
#include <optional>
class Date {
public:
// TODO: a Date when y-m-d is a real day of the Gregorian calendar, std::nullopt otherwise.
static std::optional<Date> make(int y, int m, int d) { return Date(y, m, d); }
int year() const noexcept { return y_; }
int month() const noexcept { return m_; }
int day() const noexcept { return d_; }
// TODO: the date n days later; n may be negative.
[[nodiscard]] Date add_days(long n) const {
(void)n;
return *this;
}
// TODO: the number of days from this date to other, negative when other is earlier.
[[nodiscard]] long days_until(const Date& other) const {
(void)other;
return 0;
}
auto operator<=>(const Date&) const = default; // year, month, day: the calendar's order
// TODO: divisible by 4, except century years, which must be divisible by 400.
static bool is_leap(int y) noexcept {
(void)y;
return false;
}
private:
Date(int y, int m, int d) : y_(y), m_(m), d_(d) {}
int y_;
int m_;
int d_;
}; C++ · date_tests.cpp
// Sample tests for date.h. Build: clang++ -std=c++23 -Wall -Wextra date_tests.cpp -o date_tests
// The tests use <chrono> to know the right answers; date.h itself must not.
#include <chrono>
#include <print>
#include "date.h"
int checks = 0;
int failures = 0;
void check(bool ok, const char* what) {
++checks;
if (!ok) {
++failures;
std::println("FAIL {}", what);
}
}
bool is(const Date& d, int y, int m, int day) { return d.year() == y && d.month() == m && d.day() == day; }
int main() {
check(Date::is_leap(2024) && Date::is_leap(2000), "2024 and 2000 are leap years");
check(!Date::is_leap(2023) && !Date::is_leap(1900) && !Date::is_leap(2100), "2023, 1900 and 2100 are not");
check(Date::make(2024, 2, 29).has_value(), "29 February 2024 exists");
check(!Date::make(2023, 2, 29) && !Date::make(1900, 2, 29), "29 February 2023 and 1900 do not");
check(!Date::make(2026, 4, 31) && !Date::make(2026, 13, 1) && !Date::make(2026, 0, 5) && !Date::make(2026, 5, 0),
"31 April, month 13, month 0 and day 0 are rejected");
Date feb28 = *Date::make(2024, 2, 28);
check(is(feb28.add_days(1), 2024, 2, 29) && is(feb28.add_days(2), 2024, 3, 1), "the days after 28 February 2024");
check(is(Date::make(2026, 12, 31)->add_days(1), 2027, 1, 1), "the day after 31 December");
check(is(Date::make(2026, 3, 1)->add_days(-1), 2026, 2, 28), "add_days(-1) goes back");
check(Date::make(2026, 1, 1)->days_until(*Date::make(2027, 1, 1)) == 365, "2026 has 365 days");
check(Date::make(2024, 1, 1)->days_until(*Date::make(2025, 1, 1)) == 366, "2024 has 366 days");
// 400 years, one day at a time, against the standard library's calendar.
using namespace std::chrono;
Date start = *Date::make(1900, 1, 1);
Date d = start;
sys_days expected = sys_days{year{1900} / 1 / 1};
bool same = true;
for (long k = 1; k <= 146097 && same; ++k) {
d = d.add_days(1);
expected += days{1};
year_month_day e{expected};
if (!is(d, static_cast<int>(e.year()), static_cast<int>(static_cast<unsigned>(e.month())), static_cast<int>(static_cast<unsigned>(e.day())))) same = false;
if (same && k % 1000 == 0 && start.days_until(d) != k) same = false;
}
check(same, "146,097 days in a row match <chrono>, and days_until agrees");
check(is(d, 2300, 1, 1), "400 years after 1 January 1900 is 1 January 2300");
std::println("{} of {} checks passed", checks - failures, checks);
return failures == 0 ? 0 : 1;
} The tests use <chrono> as the reference: they walk 146,097 days from the first day of 1900, one day at a time,
and compare every date your code produces with the standard library’s, which covers 400 years of month ends, leap
days and century rules. The starter code passes only the two checks that happen to hold:
from session import run
# The sample tests run against the starter code, before any TODO is done.
run("""
clang++ -std=c++23 -Wall -Wextra date_tests.cpp -o date_tests && ./date_tests
g++-16 -std=c++23 -Wall -Wextra date_tests.cpp -o date_tests-gcc && ./date_tests-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra date_tests.cpp -o date_tests && ./date_tests FAIL 2024 and 2000 are leap years FAIL 29 February 2023 and 1900 do not FAIL 31 April, month 13, month 0 and day 0 are rejected FAIL the days after 28 February 2024 FAIL the day after 31 December FAIL add_days(-1) goes back FAIL 2026 has 365 days FAIL 2024 has 366 days FAIL 146,097 days in a row match <chrono>, and days_until agrees FAIL 400 years after 1 January 1900 is 1 January 2300 2 of 12 checks passed [exit status 1] $ g++-16 -std=c++23 -Wall -Wextra date_tests.cpp -o date_tests-gcc && ./date_tests-gcc FAIL 2024 and 2000 are leap years FAIL 29 February 2023 and 1900 do not FAIL 31 April, month 13, month 0 and day 0 are rejected FAIL the days after 28 February 2024 FAIL the day after 31 December FAIL add_days(-1) goes back FAIL 2026 has 365 days FAIL 2024 has 366 days FAIL 146,097 days in a row match <chrono>, and days_until agrees FAIL 400 years after 1 January 1900 is 1 January 2300 2 of 12 checks passed [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 date_tests_session.py
Edit date.h until both compilers print 12 of 12 checks passed. One approach counts the days from a fixed day to
the date and back again, so that add_days and days_until become additions and subtractions. Keep the
constructor private: the tests can only create dates through make, and that is what keeps the invariant.
Interview questions
Fresher · What is a strong type, and why is using Paise = std::int64_t; not one?
A strong type is a distinct type that wraps a basic value, such as struct Paise { std::int64_t value; };, so that
the compiler treats it as different from every other type, even one with the same representation. A function that
takes Paise then cannot be called with Rupees or a bare integer by mistake, and only the operations you define
exist, for example adding two amounts but not multiplying them. using Paise = std::int64_t; is only an alias: a
second name for the same type, so paise, rupees and any other std::int64_t mix freely and the compiler stays
silent. A good answer adds that strong types cost nothing at run time, because the struct is the size of its single
member, and names std::chrono::duration as the standard library’s own example.
Key takeaways
- A value type copies independently, compares equal by value and shares nothing hidden; pointer members that share data break that.
- Make invalid states unrepresentable: a private constructor plus a checked factory returning
std::expectedorstd::optional, or a throwing constructor when bad values are bugs. - Once the invariant holds, member functions trust it and return new valid values; defaulted
<=>gives a natural order when the members are declared in that order. - Mark result-only functions
[[nodiscard]], true promisesnoexcept, and make types immutable through their interface, never withconstdata members. - Strong types such as
struct Paiseturn unit mix-ups into compile errors and take no more space than the number inside;usingaliases do not.
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
References
- Working Draft, Programming Languages: C++ (N4950) (ISO/IEC JTC1/SC22/WG21)
- P0323R12: std::expected (ISO/IEC JTC1/SC22/WG21)
- C++ Core Guidelines: C.11, make concrete types regular (Standard C++ Foundation)
- C++ Core Guidelines: C.12, don't make data members const or references in a copyable or movable type (Standard C++ Foundation)
- C++ Core Guidelines: C.42, if a constructor cannot construct a valid object, throw an exception (Standard C++ Foundation)
- C++ Core Guidelines: I.4, make interfaces precisely and strongly typed (Standard C++ Foundation)
- C++ Core Guidelines: Con.1, by default, make objects immutable (Standard C++ Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress