Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 25 minutes
  • Examples run with Clang 21.0.0, GCC 16.2.0 and Python 3.14.8
  • By MySmartCoPilot
Free: 25 minutes

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:

shared_state.cpp C++ · shared_state.cpp
// 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);
}
Shared data against independent copies Python · value_session.py
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:

date.cpp C++ · date.cpp
// 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);
}
Four candidates, and arithmetic on real dates Python · date_session.py
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

Numbers go through Date::make, which returns an error for an impossible day or a Date; operations on a Date stay valid.Numbers from anywhere,such as 2023, 2, 29Date::make(y, m, d)the only way instd::unexpected:"not a date"A Date, a real day.add_days, days_untiland <=> keep it realnot areal daya realday

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.

  1. Numbers for a year, a month and a day can come from anywhere, for example 2023, 2 and 29.
  2. They must go through Date::make, because the constructor of Date is private. make is the only way to create a Date.
  3. When the numbers are not a real day, make returns std::unexpected with a message that says so, and no Date exists.
  4. 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::optional gives 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::expected gives 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:

nodiscard_warning.cpp C++ · nodiscard_warning.cpp
// 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
}
Both compilers warn about the dropped result Python · nodiscard_session.py
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:

const_member.cpp C++ · const_member.cpp
// 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
}
Sorting a type with a const member Python · const_session.py
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:

units.cpp C++ · units.cpp
// 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);
}
units_error.cpp C++ · units_error.cpp
// 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); }
A silent bug, the fix, and the compile error Python · units_session.py
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 Paise is 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::duration is 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.
Date Calculator Check days between two dates, or a date plus a number of days, while you test your own Date.

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:

  1. is_leap(y): a year divisible by 4 is a leap year, except a century year, which must be divisible by 400. So 2000 was a leap year and 2100 will not be.
  2. 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 returns std::optional<Date>, because the only failure is “not a date”.
  3. add_days(n): the date n days later, for negative n too.
  4. days_until(other): the number of days from this date to other.
The starter code and its sample tests

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:

The sample tests against the starter code Python · date_tests_session.py
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::expected or std::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 promises noexcept, and make types immutable through their interface, never with const data members.
  • Strong types such as struct Paise turn unit mix-ups into compile errors and take no more space than the number inside; using aliases 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.

  1. Question 1 of 6 Tags keeps its data in std::shared_ptr<std::vector<std::string>> data_, and add() pushes onto *data_. What do the two counts print?

    Read the code, then choose one answer.

    Tags a;
    a.add("cpp");
    Tags b = a;
    b.add("value-types");
    std::println("{} {}", a.count(), b.count());
    Show the answer to question 1

    Answer: 2 2

    Copying Tags copies the shared_ptr, so a and b point to one vector, and b.add() changes what a sees. Holding the std::vector itself would make copies independent, and the counts would be 1 and 2.

  2. Question 2 of 6 Date has a private constructor and a public static std::expected<Date, std::string> make(int y, int m, int d). What does that design guarantee?

    Choose one answer.

    Show the answer to question 2

    Answer: Every Date object that exists holds a real day, so its member functions need not check again

    The only way to create a Date goes through the check in make(), which returns an error instead of a Date for an impossible day. That invariant lets add_days and the comparisons trust every Date they receive.

  3. Question 3 of 6 [[nodiscard]] Date add_days(int n) const; What happens to the statement start.add_days(30);?

    Choose one answer.

    Show the answer to question 3

    Answer: It compiles with a warning, and start does not change

    add_days returns a new Date and leaves start alone, so dropping the result does nothing useful. nodiscard asks the compiler to warn about exactly that; both Clang and GCC report it with -Wunused-result, which is on by default.

  4. Question 4 of 6 Which statement about this code is true?

    Read the code, then choose one answer.

    using Paise = std::int64_t;
    using Rupees = std::int64_t;
    Paise total(Paise price, Rupees delivery) { return price + delivery; }
    Show the answer to question 4

    Answer: It compiles, and adds rupees to paise without converting them

    An alias is only a second name for std::int64_t, so the compiler sees two integers. Strong types, such as struct Paise { std::int64_t value; }, make the same mistake a compile error.

  5. Question 5 of 6 Why does the C++ Core Guidelines rule C.12 advise against const data members in a type you copy and move?

    Choose one answer.

    Show the answer to question 5

    Answer: They make the type impossible to assign, so containers and algorithms such as sort stop working with it

    A const member cannot change after construction, so the compiler deletes the copy and move assignment operators. Sorting and erasing move elements by assignment. Keep members private and offer no setters to make a type immutable instead.

  6. Question 6 of 6 Which of these years are leap years in the Gregorian calendar?

    Choose every answer that is right.

    Show the answer to question 6

    Answer:

    • 2024
    • 2000

    A year divisible by 4 is a leap year, except century years, which must also be divisible by 400. 2000 is divisible by 400; 1900 and 2100 are centuries that are not.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.