Basic Custom Types / Classes Basic Custom Types Classes
Type Categories (simplified)
Fundamental Types |
void , bool , char , int , double , … |
Simple Aggregates |
Main Purpose: grouping data
|
More Complex Custom Types |
Main Purpose: enabling correctness/safety guarantees
|
Correctness Guarantees
- invariants = behavior and/or data properties that never change
- avoid data corruption by controlling/restricting access to data members
- restrict input/output values of functions by using dedicated types
Reusable Abstractions
- easy-to-use interfaces that hide low-level implementation details
- stable interfaces that are not affected by changing internal implementations
- reusable abstractions for commonly needed facilities (e.g., dynamic array)
Resource Management
also called RAII
(Resource Acquisition Is Initialization)
- acquire some resource (memory, file handle, connection, …) when object is constructed
- release/clean up resource when object is destroyed (de-allocate memory, close connection, …)
- stores an integer
- is initialized with 0
- Invariant: count can only increase (cannot be decreased or reset)
monotonous_counter c;
cout << c.reading(); // prints 0
c.increment();
cout << c.reading(); // prints 1
c.increment();
c.increment();
cout << c.reading(); // prints 3
A simple aggregate cannot guarantee this:
struct frail_counter {
int count;
};
frail_counter c;
cout << c.count; // any value
c.count++;
c.count = 11;
- integer member not automatically initialized to 0
- one can freely modify any integer member of an aggregate
- should store integers
- Invariant: number of elements can only increase, i.e., one can only insert new elements, but not remove them
- Invariant: elements must always be sorted in ascending order
ascending_sequence s; -
s.insert(5); - 5
s.insert(-8); - -8
- 5
s.insert(42); - -8
- 5
- 42
cout << s.size(); // prints 3
cout << s[0]; // prints -8
cout << s[2]; // prints 42
A simple aggregate cannot guarantee this:
struct chaotic_sequence {
std::vector<int> nums;
};
chaotic_sequence s; -
s.nums.push_back(8); - 8
s.nums.push_back(1); - 8
- 1
s.nums.push_back(4); - 8
- 1
- 4
s.nums.pop_back(4); - 8
- 1
-
can violate requirements
- numbers not necessarily sorted in ascending order
- we can manipulate
without restriction (delete numbers, etc.)num
std::vector
Restricted Member Access Member Access Access
Member Functions
class monotonous_counter {
int count_; // ← data member
…
void increment () { // ← member function
++count_;
}
};
class ascending_sequence {
std::vector<int> seq_; // ← data member
…
void insert (int x) { // ← member function
// insert x
into nums // at the right position
}
};
Member functions can be used to
- manipulate or query data members
- control/restrict access to data members
- hide low-level implementation details
- ensure correctness: keep/guarantee invariants
- ensure clarity: well-structured interfaces for users of type
- ensure stability: internal data representation (mostly) independent from interface
- avoid repetition/boilerplate: only one call necessary for potentially complex operations
Private members are only accessible through member functions:
class ascending_sequence {
private:
std::vector<int> seq_;
// … more private members
public:
void insert (int x) { … }
auto size () const { return seq_.size(); }
// … more public members
};
int main () {
ascending_sequence s;
s.insert(8); // 'insert' is public
auto n = s.size(); // 'size' is public
auto i = s.seq_[0]; // COMPILER ERROR: 'seq_' is private
auto m = s.seq_.size(); // COMPILER ERROR
s.seq_.push_back(1); // COMPILER ERROR
}
struct
vs. class
– main difference is default visibility:
|
= |
|
|
= |
|
keyword | usually used for |
---|---|
struct | simple aggregates of public data |
class | private data, member functions, invariants, … |
class ascending_sequence {
std::vector<int> seq_;
public: …
void insert { … }
auto size () const { return seq_.size(); }
};
int main () {
ascending_sequence s;
s.insert(88); // s is not const
auto const& cs = s;
cs.insert(5); // COMPILER ERROR: 'insert' is not const
}
A function taking a const
(reference) parameter not only
promises not to modify it, this promise is checked & enforced by the compiler:
void foo (ascending_sequence const& s) {
// 's' is const reference ^^^^^
auto n = s.size(); // 'size' is const
s.insert(5); // COMPILER ERROR: 'insert' is not const
}
class monotonous_counter {
int count_;
public: …
int reading () const {
// COMPILER ERROR: count_
is const:
count_ += 2;
return count_;
}
};
class ascending_sequence {
std::vector<int> seq_;
public: …
auto size () const { // 'seq_' is const
// COMPILER ERROR: calling non-const 'push_back'
seq_.push_back(0);
// vector's member 'size()' is const
return seq_.size();
}
};
Two member functions can have the same name (and parameter lists)
if one is const
-qualified and the other one isn't.
This makes it possible to clearly distinguish read-only access
from read/write actions.
class interpolation { …
int t_;
…
public:
…
// setter/getter pair:
void threshold (int t) { if (t > 0) t_ = t; }
int threshold () const { return t_; }
// write access to a 'node'
node& at (int x) { … }
// read-only access to a 'node'
node const& at (int x) const { … }
};
Member Declaration vs. Definition Declaration vs. Definition Declarations
class MyType {
int n_;
// more data members …
public:
// declaration + inline definition
int count () const { return n_; }
// declaration only
double foo (int, int);
};
// separate definition
double MyType::foo (int x, int y) {
// lots of stuff …
}
Definitions of complex member functions are usually put outside of the class (and into a separate source file).
However, small member functions like interface adapter functions,
getters (like count
) should be implemented inline
,
i.e., directly in the class body for maximum performance.
For now we will keep all member functions inline until we learn about Separate Compilation
Operator Member Functions Operator Functions Operators
special member function
class X { …
Y operator [] (int i) { … }
};
enables the subscript operator
:
X x;
Y y = x[0];
class ascending_sequence {
private:
std::vector<int> seq_;
public: …
void insert (int x) { … }
int operator [] (size_t index) const { return seq_[index]; }
};
int main () {
ascending_sequence s; // s.seq_: -
s.insert(9); // s.seq_: - 9
s.insert(2); // s.seq_: - 2
- 9
s.insert(4); // s.seq_: - 2
- 4
- 9
cout << s[0] << '\n'; // prints '2'
cout << s[1] << '\n'; // prints '4'
}
Init
Member Initializers C++11
class counter {
// counter should start at 0
int count_ = 0;
public:
…
};
class Foo {
int i_ = 10;
double x_ = 3.14;
public:
…
};
Constructor Initialization Lists
- constructor (
ctor
) = special member function that is executed when an object is created
class counter {
int count_;
public:
counter(): count_{0} { }
…
};
class Foo {
int i_; // 1st
double x_; // 2nd
public:
Foo(): i_{10}, x_{3.14} { }
// same order: i_ , x_
…
};
Make sure that the member order in initialization lists is always the same as the member declaration order!
A different order in the initialization list might lead to undefined behavior such as accesses to uninitialized memory.
Some compilers warn about this: g++/clang++ with -Wall or -Wreorder, — yet another reason to always activate and never ignore compiler warnings!
- constructor (
ctor
) = special member function that is executed when an object is created
- constructor's
function name
= type name - has no return type
- can initialize data members via initialization list
- can execute code before first usage of an object
- can be used to establish invariants
- default constructor = constructor that takes no parameters
class Samples {
int min_;
int max_;
std::vector v_;
public:
// default constructor:
Samples (): min_{0}, max_{1}, v_{min_,max_} { v_.reserve(8); }
explicit // special constructor:
Samples (int x): min_{x}, max_{x}, v_{x} { v_.reserve(8); }
int add (int i) { if (i < min_) min_ = i;
else if (i > max_) max_ = i;
v_.push_back(i); }
int min () const { return min_; }
int max () const { return max_; }
…
};
Samples s1; // default ctor ⇒// s1.v_: - 0
- 1
-
-
-
-
-
-
Samples s2 {3}; // special ctor ⇒// s2.v_: - 3
-
-
-
-
-
-
-
Separate Definition of Constructors
works the same as for other member functions
class MyType { …
public:
MyType (); // declaration
…
};
// separate definition
MyType::MyType (): … { … }
Make sure that the member order in initialization lists is always the same as the member declaration order!
A different order in the initialization list might lead to undefined behavior such as accesses to uninitialized memory.
Here, in the default constructor we need to make sure to
access min_
and max_
in v_{min_,max_}
only after they have been initialized.
Some compilers warn about this: g++/clang++ with -Wall or -Wreorder, — yet another reason to always activate and never ignore compiler warnings!
class BoringType { public: int i = 0; };
BoringType obj1; //
BoringType obj2 {}; //
class SomeType { …
public:
// special constructor:
explicit SomeType (int x) … { … }
};
SomeType s1 {1}; // special (int) constructor
SomeType s2; // COMPILER ERROR: no default constructor!
SomeType s3 {}; // COMPILER ERROR: no default constructor!
class MyType { …
public:
MyType () = default;
// special constructor:
explicit MyType (int x) … { … }
};
MyType m1 {1}; // special (int) constructor
MyType m2; //
MyType m3 {}; //
= default
, make sure to initialize data members
with
member initializers.// functions with a 'Counter' parameter
void foo (Counter c) { … }
void bar (Counter const& c) { … }
Implicit Conversion
class Counter {
int count_ = 0;
public:
Counter (int initial):
count_{initial} {}
…
};
// makes 'Counter' from '2':
foo(2); //
bar(2); //
foo(Counter{2}); //
bar(Counter{2}); //
explicit
Constructors
class Counter {
int count_ = 0;
public:
explicit
Counter (int initial):
count_{initial} {}
…
};
// no implicit conversion:
foo(2); // COMPILER ERROR
bar(2); // COMPILER ERROR
foo(Counter{2}); //
bar(Counter{2}); //
Make user-defined constructors explicit
by default!
Implicit conversions are a major source of hard-to-find bugs!
Only use non-explicit constructors, if direct conversions from the parameter type(s) is absolutely needed and has an unambiguous meaning.
Some older teaching materials and people coming from C++98 might tell you that one only needs to worry about implicit conversions for single-parameter constructors. This is no longer the case as of C++11 since one can also implicitly construct objects from braced lists of values.
= call
other constructor in an initialization list
class Range {
int a_;
int b_;
public:
// 1) special constructor
explicit Range (int a, int b): a_{a}, b_{b} {
if (b_ > a_) std::swap(a_,b_);
}
// 2) special [a,a] constructor - delegates to [a,b] ctor
explicit Range (int a): Range{a,a} {}
// 3) default constructor - delegates to [a,a] ctor
Range (): Range{0} {}
…
};
Range r1; // 3) ⇒ r1.a_: 0 r1.b_: 0
Range r2 {3}; // 2) ⇒ r2.a_: 3 r2.b_: 3
Range r3 {4,9}; // 1) ⇒ r3.a_: 4 r3.b_: 9
Range r4 {8,2}; // 1) ⇒ r4.a_: 2 r4.b_: 8
Can't use empty parentheses for object construction due to an ambiguity in C++'s grammar:
class A { … };
A a (); // declares function 'a'
// without parameters
// and return type 'A'
A a; // constructs an object of type A
A a {}; // constructs an object of type A
Design, Conventions & Style Design & Style Design
Each type should have exactly one purpose
because it reduces the likelihood of future modifications to it.
- reduced risk of new bugs
- keeps code depending on your type more stable
Keep data members private & use member functions to access/modify data
so that one can only interact with your type through a stable interface.
- avoids data corruption / allows guarantees of invariants
- users of your type don't need to change their code if you change the type's internal implementation
const
-qualify all non-modifying member functionsin order to clearly advertise how and when the internal state of an object changes.
- makes it harder to use your type incorrectly
- enables compiler mutability checks
- better reasoning about correctness, especially in scenarios with concurrent access to objects, e.g., from multiple threads.
Interfaces should be easy to use correctly and hard to use incorrectly.
— Scott Meyers
Users of a function or type should not be confused about its purpose, the meaning of parameters, pre/postconditions and side effects.
#include <cstdint>
#include <numeric_limits>
class monotonous_counter {
public:
// public type alias
using value_type = std::uint64_t;
private:
value_type count_ = 0;
public:
value_type reading () const { return count_; }
…
};
const auto max = std::numeric_limits<monotonous_counter::value_type>::max();
Don't leak implementation details:
- Only make type aliases public, if the aliased types are used in the public interface of your class, i.e., used as return types or parameters of public member functions.
- Do not make type aliases public if the aliased types are only used in private member functions or for private data members.
Member vs. Non-Member Member/Non-Member (Non-)Member?
How to implement a feature / add new functionality?
- only need to access public data (e.g. via member functions) ⇒ implement as free standing function
- need to access private data ⇒ implement as member function
Example: interval-like type gap
How to implement a function that makes a new
object with both bounds shifted by the same amount?gap
class gap {
int a_;
int b_;
public:
explicit gap (int a, int b): a_{a}, b_{b} {}
int a () const { return a_; }
int b () const { return b_; }
};
Free-Standing Function
gap shifted (gap const& g, int x) {
return gap{g.a()+x, g.b()+x};
}
- implementation only depends on the public interface of
gap
- we didn't change type
gap
itself ⇒ other code depending on it doesn't need to be recompiled
Member Function
class gap {
…
gap shifted (int x) const {
return gap{a_+x, b_+x};
}
};
- other users of
gap
might want ashifted
function with different semantics, but they are now stuck with ours - all other code depending on
gap
needs to recompile
- use
action
/verb
functions instead of justsetters
- usually models problems better
- more fine-grained control
- better code readability / expression of intent
descriptive actions
class Account { …
void deposit (Money const&);
Money try_withdraw (Money const&);
Money const& balance () const;
};
setter/getter pair
class Account { …
void set_balance (Money const&);
Money const& balance () const;
};
Understandable
class IPv6_Address {…};
class ThreadPool {…};
class cuboid {…};
double volume (cuboid const&) {…}
Too generic
class Manager {…};
class Starter {…};
class Pool {…};
int get_number (Pool const&) {…}
Example Style 1
class type_name {…};
int free_function () {…}
int member_function () {…}
int localVariable;
int memberVariable_;
Example Style 2
class TypeName {…};
int free_function () {…}
int memberFunction () {…}
int localVariable;
int memberVariable_;
Do not use leading underscores or double underscores in names of types, variables, functions, private data members, etc.!
Names beginning with underscores and/or containing double underscores are reserved for the standard library and/or compiler-generated entities.
Using names with leading underscores or double underscores can invoke undefined behavior!
A common and unproblematic convention is to use trailing underscores for private data members.
_member m_ember
member__ member_
m__ember
- to restrict input parameter values
- to ensure validity of intermediate results
- to guarantee return value validity
⇒ compiler as correctness checker if it compiles, it should be correct
// unambiguous interface:
double volume (Cuboid const&);
// input guarantee: angle is in radians
Square make_rotated (Square const&, Radians angle);
// input: only valid quantity (e.g. > 0)
Gadget duplicate (Gadget const& original, Quantity times);
// result guarantee: vector is normalized
UnitVector3d dominant_direction (WindField const&);
// avoid confusion with a good units library
si::kg mass (EllipsoidShell const&, si::g_cm3 density);
bool has_cycles (DirectedGraph const&);
// understandable control flow & logic:
Taxon species1 = classify(image1);
Taxon species2 = classify(image2);
Taxon lca = taxonomy.lowest_common_ancestor( species1, species2);
Example Implementations Complete Examples Example
- new counters start at 0
- can only count up, not down
- read-only access to current count value
#include <iostream> // std::cout
#include <cstdint> // std::uint64_t
class monotonous_counter {
public:
using value_type = std::uint64_t;
private:
value_type count_ = 0; // initial
public:
monotonous_counter () = default;
explicit monotonous_counter (value_type init) noexcept: count_{init} {}
void increment () noexcept { ++count_; }
[[nodiscard]] value_type reading () const noexcept { return count_; }
};
int main () {
monotonous_counter c;
c.increment();
std::cout << c.reading() << '\n'; // prints 1
c.increment();
c.increment();
std::cout << c.reading() << '\n'; // prints 3
}
- stores integers
- read-only access to stored elements by index
- can only insert new elements, but not remove them
- elements are always sorted in ascending order
- content only modifiable through public interface
The implementation of 'insert' and the role of the 'begin' and 'end' member functions will become much clearer after we have learned about iterators and the algorithms in the standard library.
#include <iostream> // std::cout
#include <vector> // std::vector
#include <algorithm> // std::lower_bound
class ascending_sequence {
public:
using value_type = int;
private:
using storage_t = std::vector<value_type>;
storage_t seq_;
public:
using size_type = storage_t::size_type;
void insert (value_type x) {
// use binary search to find insert position
seq_.insert( std::lower_bound( seq_.begin(), seq_.end(), x), x);
}
[[nodiscard]] value_type operator [] (size_type idx) const noexcept {
return seq_[idx]; }
[[nodiscard]] size_type size () const noexcept { return seq_.size(); }
// enable range based iteration
[[nodiscard]] auto begin () const noexcept { return seq_.begin(); }
[[nodiscard]] auto end () const noexcept { return seq_.end(); }
};
int main () {
ascending_sequence s; // s.seq_: -
s.insert(7); // s.seq_: - 7
s.insert(2); // s.seq_: - 2
- 7
s.insert(4); // s.seq_: - 2
- 4
- 7
s.insert(9); // s.seq_: - 2
- 4
- 7
- 9
s.insert(5); // s.seq_: - 2
- 4
- 5
- 7
- 9
std::cout << s[3] << '\n'; // prints 7
for (auto x : s) {
std::cout << x <<' '; // 2 4 5 7 9
}
std::cout << '\n';
// use type aliases
ascending_sequence::value_type x = 1;
ascending_sequence::size_type n = 2;
}
Related …
- Regular Types and why do I care? (Victor Ciura, 2018)
- How I learned to Stop Worrying and Love the C++ Type System (Peter Sommerlad, 2019)
- Back to Basics: Designing Classes [1/2] (Klaus Iglberger, 2021)
- Back to Basics: Designing Classes [2/2] (Klaus Iglberger, 2021)
- Back To Basics: The Special Member Functions (Klaus Iglberger, 2021)
Comments…