Designing Classes
Software must be adaptable to frequent changes
- Design Guidelines
- Implementation Guidelines
- Reference
Design Guidelines
- Spent time to find good names for all entities
Designing Hierarchy
---
title: Shape Hierarchy
---
classDiagram
class Shape
Shape : +virtual draw() = 0
Shape : +virtual serialize() = 0
OpenGLLittleEndianCircle : +virtual draw()
OpenGLLittleEndianCircle : +virtual serialize()
OpenGLBigEndianCircle : +virtual draw()
OpenGLBigEndianCircle : +virtual serialize()
MetalLittleEndianCircle : +virtual draw()
MetalLittleEndianCircle : +virtual serialize()
MetalBigEndianCircle : +virtual draw()
MetalBigEndianCircle : +virtual serialize()
OpenGLSquare: +virtual draw()
MetalSquare: +virtual draw()
OpenGLLittleEndianSquare : +virtual serialize()
OpenGLBigEndianSquare : +virtual serialize()
Shape <|-- Circle
Shape <|-- Square
Circle <|-- OpenGLLittleEndianCircle
Circle <|-- OpenGLBigEndianCircle
Circle <|-- MetalLittleEndianCircle
Circle <|-- MetalBigEndianCircle
Square <|-- OpenGLSquare
Square <|-- MetalSquare
OpenGLSquare <|-- OpenGLLittleEndianSquare
OpenGLSquare <|-- OpenGLBigEndianSquare
Using inheritance naively to solve our problem easily leads to …
- many derived classes
- ridiculous class names
- deep inheritance hierarchies
- duplication between similar implementations
- (almost) impossible extensions
- impeded maintenance
Resist urge to put everything into one class. Separate concerns!
Design classes for easy change/extensions.
Solution
The Single-Responsibility Principle (SRP)
Everything should do just one thing.
Separate concerns to isolate and simplify change.
- Separation of Concerns
- High cohesion / low coupling
- Orthogonality
The Open-Closed Principle (OCP)
Prefer design that simplifies the extension by types or operations
Don’t Repeat Yourself (DRY)
Reduce duplication in order to simplify change.
The Strategy Design Pattern
---
title: The Strategy Design Pattern
---
classDiagram
class Circle
class DrawStrategy
Circle: +draw()
DrawStrategy: +virtual draw(Circle) = 0
OpenGLStrategy: +virtual draw(Circle)
TestStrategy: +virtual draw(Circle)
Circle *-- DrawStrategy
DrawStrategy <|-- OpenGLStrategy
DrawStrategy <|-- TestStrategy
note for Circle "Represents any concrete shape"
note for DrawStrategy "`draw` is extracted and isolated;
fulfilling the Single-Responsibility Principle"
note for TestStrategy "New 'responsibilities' can be added w/o modifying existing code;
fulfilling the Open-Closed Principle"
class Circle;
using DrawCircleStrategy = std::function<void(Circle const&)>
class Circle : public Shape
{
public:
Circle(double radius, DrawCircleStrategy strategy) // Dependency Injection
: m_radius { radius }
, // ... remaining data members
, m_drawing { std::move(strategy) }
{}
// ...
private:
double m_radius;
// ...
DrawCircleStrategy m_drawing;
}
// Using template for the same intent
template<typename DrawStrategy> // Dependency Injection
class Square : public Shape
{
public:
Square(double side)
: m_side { side }
, // ... remaining data members
{}
// ...
void draw(/* ... */) const override
{
DrawStrategy{}(this, /* ... */);
}
private:
double m_side;
// ...
}
class OpenGLCircleStrategy : public DrawCircleStrategy
{
public:
// ...
void draw(const Circle&) const override;
}
class OpenGLSquareStrategy : public DrawSquareStrategy
{
public:
// ...
void draw(const Circle&) const override;
}
- Extracted implementation details (SRP)
- Created the opportunity for easy change / extension (OCP)
- reduced duplication (DRY)
- Limited the depth of the inheritance hierarchy
= Simplified maintenance
The Template Method Design Pattern
class PersistenceInterface
{
public:
// ...
virtual bool write(const Blob& blob) = 0;
virtual bool write(const Blob& blob, WriteCallback callback) = 0;
virtual bool read(Blob& blob, uint timeout) = 0;
virtual bool read(Blob& blob, ReadCallback callback, uint timeout) = 0;
}
The virtual functions may pose a problem in the future …
- they represent the interface to caller
- they represent the interface for deriving classes
= don’t separate concerns -> potentially introduces a lot of duplication = make changes harder
Solution
class PersistenceInterface
{
public:
// ...
bool write(const Blob& blob) = 0;
bool write(const Blob& blob, WriteCallback callback) = 0;
bool read(Blob& blob, uint timeout) = 0;
bool read(Blob& blob, ReadCallback callback, uint timeout) = 0;
private:
virtual bool write(const Blob& blob) = 0;
virtual bool write(const Blob& blob, WriteCallback callback) = 0;
virtual bool read(Blob& blob, uint timeout) = 0;
virtual bool read(Blob& blob, ReadCallback callback, uint timeout) = 0;
}
Non-Virtual Interface Idiom (NVI)
- Separation of concern
- Public interface / Derived class
- Internal changes have no impact on callers
- Reduced duplication (DRY)
Implementation Guidelines
Resource Management
The Rule of 0
class Widget
{
public:
// No default operations declared
~Widget() = default;
private:
int i; // representative of a fundamental type
std::string s; // representative of a user-defined type
}
Core Guideline C.20: If you can avoid defining default operations, do
Resource Ownership
class Widget
{
public:
Widget() = default;
Widget(const Widget&) = default;
Widget(Widget&&) noexcept = default;
Widget& operator=(const Widget&) = default;
Widget& operator=(Widget&&) noexcept = default;
~Widget() { delete p }; // Not ideal - as pointer is used for a owned resource
private:
int i; // representative of a fundamental type
std::string s; // representative of a user-defined type
Resource* p; // representative of a possible resource
}
Core Guideline C.32: If a class has a raw pointer (T*
) or reference (T&
), consider whether it might be owning
Core Guideline C.33: If a class has an owning pointer member, define a destructor
Core Guideline R.3: A raw pointer (T*
) is non-owning
Core Guideline R.1: Manager resources automatically using resource handles and RAII (Resource Acquisition Is Initialization)
RAII is the most important idiom in C++.
The Rule of 5
class Widget
{
public:
Widget() = default;
Widget(const Widget&); // User must define it - because of `unique_ptr`
Widget(Widget&&) noexcept = default;
Widget& operator=(const Widget&); // User must define it - because of `unique_ptr`
Widget& operator=(Widget&&) noexcept = default;
~Widget() { delete p };
private:
int i; // representative of a fundamental type
std::string s; // representative of a user-defined type
std::unique_ptr<Resource> p; // unique_ptr CANNOT be copied
}
Strive for the rule of 0: Classes that don’t require an explicit destructor, copy operations and move operations are much easier to handle.
Core Guideline C.21: If you define or =delete
any default operation, define or =delete
them all
Data Member Initialization
Default Initialization vs Value Initialization
struct Widget
{
int i; // w1: Uninitialized / w2: Initialized to 0
std::string s; // w1: Default (empty) / w2: Default (empty)
int* pi; // w1: Uninitialized / w2: Initialized to nullptr
}
int main()
{
Widget w1; // Default initialization calls the default constructor
Widget w2 {}; // Value initialization
}
If no default constructor is declared, value initialization do …
- zero-initializes the object
- and then default-initializes all non-trivial data members
Prefer to create default objects by means of an empty set of braces (value initialization)
Empty Default Constructor
struct Widget
{
Widget() {} // Explicit default constructor
int i; // w1 & w2: Uninitialized
std::string s; // w1 & w2: Default (empty)
int* pi; // w1 & w2: Uninitialized
}
int main()
{
Widget w1; // Default initialization calls the default constructor
Widget w2 {}; // Value initialization -> call default constructor
}
An empty default constructor …
- initializes all data members of class type
- but not the data members of fundamental type
Avoid writing empty default constructor
Member Initialization List
struct Widget
{
Widget()
: Widget(30) // Delegating constructor
{}
Widget(int j)
: i { j }
{} // The lifetime of the object begins with the closing brace of the delegated constructor
// Data members with in-class initializers
int i { 42 };
std::string s { "CppCon" };
int* pi { nullptr };
}
Core Guideline C.44: Prefer default constructors to be simple and non-throwing
Core Guideline C.47: Define and initialize member variables in the order of member declaration
Core Guideline C.48: Prefer in-class initializers to member initializers in constructors for constant initializers Core Guideline C.49: Prefer initialization to assignment in constructor
Core Guideline C.51: Use delegating constructors to represent common actions for all constructors of a class
Member initialization order follows the order of member declaration.
Implicit Conversions
struct Widget
{
explicit Widget(int) { std::puts("Widget(int)"); }
}
void f(Widget);
int main()
{
f(42); // Compilation error! No matching function for 'f(int)'
f(Widget(42)); // Calls `f(Widget)`
}
Core Guideline C.46: By default, declare single-argument constructors explicit
Order of Member Data
struct Widget
{
bool b1; // char padding1[7];
double d; // Needs to be 8-byte aligned on x64
bool b2; // char padding2[7];
}
struct Widget2
{
double d; // Largest first
bool b1;
bool b2; // char padding[6];
}
int main()
{
std::cout << sizeof(Widget) << std::endl; // prints 24
std::cout << sizeof(Widget2) << std::endl; // prints 16
}
Consider the alignment of data members when adding member data to a struct or class
Const Correctness
template <typename Type, size_t Capacity>
class FixedVector
{
public:
using iterator = Type*;
using const_iterator = const Type*;
iterator begin() noexcept;
const_iterator begin() const noexcept;
iterator end() noexcept;
const_iterator end() const noexcept;
// ...
}
Core Guideline Con.2: By default, make member functions const
Make getters const
with const reference such as const Type&
unless there is a strong reason not to.
Qualified / Modified Member Data
struct Widget
{
const int i;
double& d;
// Widget& operator=(const Widget&) // Implicitly deleted
// Widget& operator=(Widget&&) // Not declared
}
Assignment to const data members or references doesn’t work, so the compiler cannot generate assignment operators.
Solution
#include <functional>
class Widget
{
public:
double& get() noexcept { return d; }
const double& get() const noexcept { return d; }
private:
std::reference_wrapper<double> d;
}
Use pointer or a reference_wrapper
instead of a reference member.
Unless there is a strong reason to, do not make a member variable const
. (Make variable private
and don’t have setter is enough in most cases)
Core Guideline C.12: Don’t make data members const
or references
Visibility vs Accessibility
Remember the four steps of the compiler to resolve a function call:
- Name lookup: Select all candidate functions with a certain name within the current scope. If none is found, proceed into the next surrounding scope.
- Overload resolution: Find the best match among the selected candidate functions. If necessary, apply the necessary argument conversions.
- Access labels: Check if the best match is accessible from the given call site.
- =delete: Check if the best match has been explicitly deleted.