Inheriting, Converting, Aggregate, and constexpr Constructors
Beyond the basics, C++ offers several specialized constructor forms that solve specific problems: inheriting base-class constructors to reduce boilerplate, std::initializer_list for brace-initialization, aggregate initialization for simple structs, designated initializers (C++20) for named fields, and constexpr constructors for compile-time objects.
HOW TO CHOOSE: - If your derived class adds no new state, use inheriting constructors. - If your class should accept {1, 2, 3} syntax, add an initializer_list ctor. - If your class is just a bundle of data, make it an aggregate. - If your object can be fully built at compile time, make the ctor constexpr.
1. Inheriting constructors (C++11) — using Base::Base
How It Works Deep Dive
When a derived class adds no new data members (or all new members
have default member initializers), you can import ALL constructors
from the base class with "using Base::Base;". The compiler generates
a derived-class constructor for each base-class constructor that
simply forwards the arguments to the base.
WHAT THE COMPILER GENERATES (conceptually):
Derived(int x, string s) : Base(x, s) {} // for each Base ctor
WHY IT MATTERS:
Without this, a derived class with a 5-argument base would need
to manually write forwarding constructors for every combination.
Watch out: inherited constructors do NOT initialize new members
added by the derived class. Use default member initializers for
those, or the new members will be left uninitialized (UB for
built-in types). class Animal {
protected:
std::string name_;
int age_;
public:
Animal(std::string name, int age)
: name_(std::move(name)), age_(age) {}
Animal(std::string name)
: name_(std::move(name)), age_(0) {}
void info() const {
std::cout << std::format(" {}, age {}", name_, age_);
}
};
class Dog : public Animal {
std::string breed_ = "Unknown"; // default member initializer — safe!
public:
using Animal::Animal; // inherit ALL constructors from Animal
// Add a constructor that also takes breed
Dog(std::string name, int age, std::string breed)
: Animal(std::move(name), age), breed_(std::move(breed)) {}
void info() const {
Animal::info();
std::cout << std::format(", breed: {}\n", breed_);
}
};2. std::initializer_list constructor
How It Works Deep Dive
std::initializer_list<T> is a lightweight wrapper around a temporary
array of T. When you write MyClass obj{1, 2, 3}, the compiler creates
a temporary array {1, 2, 3} and passes an initializer_list pointing
to it. The list is valid only during the constructor call. HOW OVERLOAD RESOLUTION WORKS: Deep Dive
When you use {} initialization and an initializer_list constructor
exists, the compiler STRONGLY PREFERS it over other constructors.
This is why std::vector<int>{10} creates a vector with one element
(10), not ten elements — the initializer_list ctor wins.
Watch out: if you provide both a regular constructor and an
initializer_list constructor, {} always picks the initializer_list
version. Use () to call the regular constructor:
MyVec v(5, 0); // calls MyVec(size_t count, int value)
MyVec v{5, 0}; // calls MyVec(initializer_list<int>) with {5, 0} class IntList {
std::vector<int> data_;
std::string label_;
public:
// Regular constructor: create n copies of value
IntList(std::size_t count, int value, std::string label = "list")
: data_(count, value), label_(std::move(label)) {}
// Initializer list constructor: accept {1, 2, 3, ...}
IntList(std::initializer_list<int> init, std::string label = "list")
: data_(init), label_(std::move(label))
{
std::cout << std::format(" initializer_list ctor called with {} elements\n",
init.size());
}
void print() const {
std::cout << std::format(" [{}]: ", label_);
for (int n : data_) std::cout << n << ' ';
std::cout << std::format("(size={})\n", data_.size());
}
};3. Aggregate initialization — no constructor needed
How It Works Deep Dive
An aggregate is a class/struct with:
- No user-declared constructors (C++20 relaxes: no user-DECLARED)
- No private/protected non-static data members
- No virtual functions
- No virtual/private/protected base classes
Aggregates can be initialized with {value1, value2, ...} without
any constructor. The compiler initializes members in declaration
order. Missing values are value-initialized (zero for built-ins,
default-constructed for classes).
WHY AGGREGATES MATTER:
They are the simplest data types — just a bundle of fields.
No invariants to enforce, no encapsulation needed. Use them for
DTOs, configuration structs, and return types.
Watch out: adding a user-declared constructor (even = default
inside the class body in C++17 and earlier) removes aggregate
status. In C++20, = default inside the class is OK. struct Point3D {
double x;
double y;
double z;
// No constructors — this is an aggregate
double magnitude() const {
return std::sqrt(x * x + y * y + z * z);
}
};
struct Color {
uint8_t r = 0;
uint8_t g = 0;
uint8_t b = 0;
uint8_t a = 255; // default alpha: fully opaque
// Aggregate with default member initializers
};4. Designated initializers (C++20) — named field initialization
How It Works Deep Dive
When initializing an aggregate, you can name the fields:
Point3D p{.x = 1.0, .y = 2.0, .z = 3.0};
Un-named fields use their default member initializer or are
value-initialized (zero).
RULES:
- Designators must appear in declaration order (unlike C99).
- You can skip fields, but you can't reorder them.
- Only works with aggregates (no user-declared constructors).
Watch out: C++ designated initializers are MORE restrictive than
C99 designated initializers. You cannot use out-of-order
designators or designate array elements. This is because C++
guarantees left-to-right evaluation order. struct ServerConfig {
std::string host = "localhost";
int port = 8080;
int max_connections = 100;
bool tls_enabled = false;
int timeout_ms = 30000;
};5. Converting constructor vs explicit — how implicit conversion works
HOW IMPLICIT CONVERSION HAPPENS: When a function expects type A but receives type B, the compiler looks for a way to convert B → A. If A has a non-explicit constructor that accepts B, the compiler silently creates a temporary A from B. This is called an "implicit converting constructor." THE CONVERSION CHAIN: The compiler will apply AT MOST ONE user-defined implicit conversion. So if A(B) exists and B(C) exists, passing a C where A is expected does NOT work — that would require two user-defined conversions. takes a non-const reference (A&), an implicit conversion won't bind because temporaries can't bind to non-const refs. This is a common source of "no matching function" errors.
Watch out: the conversion creates a TEMPORARY. If a function
class Kilometers {
double value_;
public:
// NOT explicit — allows implicit conversion from double
Kilometers(double v) : value_(v) {}
double value() const { return value_; }
};
class Miles {
double value_;
public:
// explicit — prevents accidental implicit conversion
explicit Miles(double v) : value_(v) {}
double value() const { return value_; }
};
void log_distance_km(Kilometers km) {
std::cout << std::format(" Distance: {:.2f} km\n", km.value());
}
void log_distance_mi(Miles mi) {
std::cout << std::format(" Distance: {:.2f} mi\n", mi.value());
}6. constexpr constructors — compile-time objects
How It Works Deep Dive
A constexpr constructor allows the class to be used in constant
expressions. When you write "constexpr MyClass obj{...};", the
ENTIRE object is constructed at compile time and embedded in the
binary as constant data — zero runtime cost.
REQUIREMENTS (C++14+):
- The constructor body can have statements (loops, conditionals).
- All member types must be literal types (scalars, aggregates,
classes with constexpr constructors).
- No dynamic allocation (new/delete) unless C++20 transient.
Watch out: "constexpr MyClass obj{...};" guarantees compile-time.
"MyClass obj{...};" with a constexpr constructor does NOT — it
might run at runtime. Use constexpr/consteval to force it. class Vec2 {
double x_, y_;
public:
constexpr Vec2() : x_(0.0), y_(0.0) {}
constexpr Vec2(double x, double y) : x_(x), y_(y) {}
constexpr Vec2 operator+(const Vec2& other) const {
return {x_ + other.x_, y_ + other.y_};
}
constexpr Vec2 operator*(double scalar) const {
return {x_ * scalar, y_ * scalar};
}
constexpr double dot(const Vec2& other) const {
return x_ * other.x_ + y_ * other.y_;
}
constexpr double magnitude_squared() const {
return x_ * x_ + y_ * y_;
}
constexpr double x() const { return x_; }
constexpr double y() const { return y_; }
};
// Compile-time lookup table built using constexpr constructor
constexpr auto build_unit_vectors() {
std::array<Vec2, 8> dirs{};
for (int i = 0; i < 8; ++i) {
double angle = i * 3.14159265358979 / 4.0;
// Note: std::cos/sin are not constexpr in C++20 standard,
// so we use a simple approximation for demonstration
dirs[i] = Vec2(
(i == 0 || i == 7 || i == 1) ? 1.0 :
(i == 3 || i == 4 || i == 5) ? -1.0 : 0.0,
(i == 1 || i == 2 || i == 3) ? 1.0 :
(i == 5 || i == 6 || i == 7) ? -1.0 : 0.0
);
}
return dirs;
}
constexpr auto unit_directions = build_unit_vectors();7. Conversion operators — the other direction
HOW THEY RELATE TO CONSTRUCTORS:
A converting constructor converts FROM another type TO your class.
A conversion operator converts FROM your class TO another type.
Together they define how your class interacts with the type system.
operator T() const { return ...; } — implicit conversion to T
explicit operator T() const { return ...; } — explicit only
surprising overload ambiguity and silent bugs. Always prefer
explicit conversion operators. The notable exception is
operator bool(), which is almost always explicit. Watch out: implicit conversion operators (non-explicit) can cause
class Percentage {
double value_; // 0.0 to 100.0
public:
explicit Percentage(double v) : value_(v) {}
// Explicit conversion to double — requires static_cast or if()
explicit operator double() const { return value_ / 100.0; }
// Explicit conversion to bool — allows if(pct) but not int x = pct
explicit operator bool() const { return value_ > 0.0; }
double value() const { return value_; }
};Key Takeaways
- •Use "using Base::Base;" to inherit all base-class constructors when the derived class adds no new uninitialized members.
- •An initializer_list constructor is STRONGLY preferred during {} initialization. Use () to bypass it when needed.
- •Aggregates are the simplest data types — no constructors needed. Use designated initializers (C++20) for readable initialization.
- •constexpr constructors enable compile-time object creation — embed lookup tables and constants directly in the binary.
- •Mark constructors and conversion operators explicit unless you intentionally want implicit conversions (which is rare).
int main() {
// ---- 1. Inheriting constructors ----
std::cout << "--- Inheriting Constructors ---\n";
Dog d1{"Rex", 5}; // uses inherited Animal(string, int)
Dog d2{"Buddy"}; // uses inherited Animal(string)
Dog d3{"Max", 3, "Husky"}; // uses Dog's own 3-arg ctor
d1.info();
d2.info();
d3.info();
// ---- 2. std::initializer_list ----
std::cout << "\n--- std::initializer_list ---\n";
IntList from_init{1, 2, 3, 4, 5}; // initializer_list ctor
IntList from_count(5, 0); // regular ctor: 5 zeros
from_init.print();
from_count.print();
// Gotcha: {} prefers initializer_list
IntList gotcha{5, 0}; // initializer_list with {5, 0}, NOT 5 zeros!
gotcha.print(); // prints: 5 0 (size=2)
// ---- 3. Aggregate initialization ----
std::cout << "\n--- Aggregate Initialization ---\n";
Point3D origin{}; // all zeros
Point3D p{1.0, 2.0, 3.0}; // direct
std::cout << std::format(" origin: ({},{},{}) magnitude={:.2f}\n",
origin.x, origin.y, origin.z, origin.magnitude());
std::cout << std::format(" p: ({},{},{}) magnitude={:.2f}\n",
p.x, p.y, p.z, p.magnitude());
// Aggregate with default member initializers
Color red{255, 0, 0}; // a defaults to 255
Color semi{128, 128, 128, 50}; // override alpha
std::cout << std::format(" red: rgba({},{},{},{})\n", red.r, red.g, red.b, red.a);
std::cout << std::format(" semi: rgba({},{},{},{})\n", semi.r, semi.g, semi.b, semi.a);
// ---- 4. Designated initializers (C++20) ----
std::cout << "\n--- Designated Initializers (C++20) ---\n";
ServerConfig cfg{
.host = "prod.example.com",
.port = 443,
// .max_connections skipped — uses default 100
.tls_enabled = true,
// .timeout_ms skipped — uses default 30000
};
std::cout << std::format(" host={} port={} max_conn={} tls={} timeout={}ms\n",
cfg.host, cfg.port, cfg.max_connections,
cfg.tls_enabled, cfg.timeout_ms);
// ---- 5. Converting constructors ----
std::cout << "\n--- Converting vs Explicit Constructors ---\n";
log_distance_km(42.0); // OK: implicit Kilometers(42.0)
log_distance_km(Kilometers{42.0}); // OK: explicit construction
// log_distance_mi(42.0); // ERROR: Miles ctor is explicit
log_distance_mi(Miles{42.0}); // OK: explicit construction
// ---- 6. constexpr constructors ----
std::cout << "\n--- constexpr Constructors ---\n";
constexpr Vec2 a{3.0, 4.0};
constexpr Vec2 b{1.0, 2.0};
constexpr Vec2 c = a + b;
constexpr double d = a.dot(b);
static_assert(c.x() == 4.0);
static_assert(c.y() == 6.0);
static_assert(d == 11.0);
std::cout << std::format(" ({},{}) + ({},{}) = ({},{})\n",
a.x(), a.y(), b.x(), b.y(), c.x(), c.y());
// Compile-time lookup table
static_assert(unit_directions[0].x() == 1.0); // East
std::cout << std::format(" unit_directions[0] (East): ({},{})\n",
unit_directions[0].x(), unit_directions[0].y());
std::cout << std::format(" unit_directions[2] (North): ({},{})\n",
unit_directions[2].x(), unit_directions[2].y());
// ---- 7. Conversion operators ----
std::cout << "\n--- Conversion Operators ---\n";
Percentage pct{75.0};
double ratio = static_cast<double>(pct); // explicit conversion required
std::cout << std::format(" {}% as ratio: {:.2f}\n", pct.value(), ratio);
if (pct) { // explicit operator bool — works in boolean context
std::cout << " Percentage is non-zero\n";
}
// int x = pct; // ERROR: no implicit conversion to int
Percentage zero{0.0};
if (!zero) { // operator bool returns false for 0%
std::cout << " Zero percentage is falsy\n";
}
return 0;
}