C++ Best Practices
Modern C++ provides powerful features that, when used correctly, lead to clean, efficient, and maintainable code. This lesson covers essential idioms and best practices.
RAII: Resource Acquisition Is Initialization
The fundamental C++ idiom: tie resource lifetime to object lifetime.
// Bad: Manual resource management
void process_file() {
FILE* file = fopen("data.txt", "r");
// ... process ...
fclose(file); // Easy to forget!
}
// Good: RAII with smart pointers
void process_file() {
auto file = std::unique_ptr<FILE, decltype(&fclose)>(
fopen("data.txt", "r"), fclose
);
// Automatically closed
}
// Better: Use RAII wrappers
class File {
FILE* file_;
public:
explicit File(const char* path) : file_(fopen(path, "r")) {
if (!file_) throw std::runtime_error("Failed to open file");
}
~File() { if (file_) fclose(file_); }
File(const File&) = delete;
File& operator=(const File&) = delete;
FILE* get() const { return file_; }
};
Rule of Zero/Three/Five
Rule of Zero
If you don’t need custom resource management, don’t define special members:
// Good: Compiler-generated special members work perfectly
struct Person {
std::string name;
int age;
std::vector<std::string> hobbies;
// Compiler generates correct copy/move/destructor
};
Rule of Five
If you define one special member, define all:
class Resource {
int* data_;
public:
// Constructor
Resource(int size) : data_(new int[size]) {}
// Destructor
~Resource() { delete[] data_; }
// Copy constructor
Resource(const Resource& other)
: data_(new int[other.size()]) {
std::copy(other.data_, other.data_ + size(), data_);
}
// Copy assignment
Resource& operator=(const Resource& other) {
if (this != &other) {
delete[] data_;
data_ = new int[other.size()];
std::copy(other.data_, other.data_ + size(), data_);
}
return *this;
}
// Move constructor
Resource(Resource&& other) noexcept
: data_(other.data_) {
other.data_ = nullptr;
}
// Move assignment
Resource& operator=(Resource&& other) noexcept {
if (this != &other) {
delete[] data_;
data_ = other.data_;
other.data_ = nullptr;
}
return *this;
}
};
Better: Use RAII Members
class Resource {
std::unique_ptr<int[]> data_;
size_t size_;
public:
Resource(size_t size)
: data_(std::make_unique<int[]>(size)), size_(size) {}
// Rule of Zero: compiler-generated special members work!
};
Const Correctness
class String {
std::string data_;
public:
// Read-only access: const method
size_t length() const { return data_.length(); }
const char* c_str() const { return data_.c_str(); }
// Modification: non-const method
void append(const std::string& str) { data_ += str; }
// Const and non-const overloads
char& operator[](size_t i) { return data_[i]; }
const char& operator[](size_t i) const { return data_[i]; }
};
// Use const references to avoid copies
void print(const String& str) { // Won't copy
std::cout << str.c_str() << '\n';
}
Prefer References to Pointers
// Bad: Nullable, unclear ownership
void process(int* value) {
if (value) { // Must check for null
*value += 10;
}
}
// Good: Not nullable, clear value semantics
void process(int& value) {
value += 10; // Always valid
}
// When nullability is needed: std::optional
void process(std::optional<int>& value) {
if (value) {
*value += 10;
}
}
Initialization
Prefer Uniform Initialization
// Direct initialization
int x{42};
std::string name{"Alice"};
std::vector<int> vec{1, 2, 3, 4, 5};
// Avoid narrowing conversions
int i{3.14}; // Error: narrowing
// For copy construction, use auto
auto str = std::string("Hello"); // Clear intent
Member Initialization
class Config {
// In-class initializers (C++11)
int timeout = 30;
std::string host = "localhost";
bool debug = false;
public:
// Constructor: only set non-default values
Config(std::string h) : host(std::move(h)) {}
};
Use auto Appropriately
// Good: Obvious type
auto count = 42;
auto name = std::string("Alice");
auto ptr = std::make_unique<Widget>();
// Good: Complex types
auto it = vec.begin();
auto result = some_function_with_long_return_type();
// Good: Generic code
template<typename T>
auto process(const T& value) {
auto result = value.compute();
return result;
}
// Bad: Type not obvious
auto x = get_value(); // What type is x?
// Better: Explicit when clarity helps
std::string x = get_value();
Range-Based For Loops
std::vector<int> vec{1, 2, 3, 4, 5};
// Read-only: const reference
for (const auto& value : vec) {
std::cout << value << ' ';
}
// Modify: reference
for (auto& value : vec) {
value *= 2;
}
// Copy when needed
for (auto value : vec) {
value += 1; // Modifies copy
}
Algorithms Over Raw Loops
// Bad: Manual loop
int sum = 0;
for (size_t i = 0; i < vec.size(); ++i) {
sum += vec[i];
}
// Good: Algorithm expresses intent
int sum = std::accumulate(vec.begin(), vec.end(), 0);
// Better: Ranges (C++20)
int sum = std::ranges::fold_left(vec, 0, std::plus{});
More examples:
// Find element
auto it = std::find(vec.begin(), vec.end(), 42);
// Transform
std::vector<int> doubled;
std::transform(vec.begin(), vec.end(),
std::back_inserter(doubled),
[](int x) { return x * 2; });
// Filter
std::vector<int> evens;
std::copy_if(vec.begin(), vec.end(),
std::back_inserter(evens),
[](int x) { return x % 2 == 0; });
Error Handling
Exceptions for Exceptional Cases
// Use exceptions for errors you can't handle locally
void process_file(const std::string& path) {
std::ifstream file(path);
if (!file) {
throw std::runtime_error("Cannot open file: " + path);
}
// Process...
}
// Handle where you can recover
try {
process_file("data.txt");
} catch (const std::exception& e) {
std::cerr << "Error: " << e.what() << '\n';
// Fallback action
}
Expected for Expected Errors
// Use std::expected for errors that are expected
std::expected<int, std::string> parse_int(const std::string& str) {
try {
return std::stoi(str);
} catch (...) {
return std::unexpected("Invalid integer");
}
}
auto result = parse_int(input);
if (result) {
use(*result);
} else {
handle_error(result.error());
}
Avoid Premature Optimization
// Don't do this without profiling:
void process() {
// "Optimization": manually unrolled loop, hard to read
for (size_t i = 0; i < n; i += 4) {
result[i] = data[i] * 2;
result[i+1] = data[i+1] * 2;
result[i+2] = data[i+2] * 2;
result[i+3] = data[i+3] * 2;
}
}
// Instead: Write clear code, let compiler optimize
void process() {
for (size_t i = 0; i < n; ++i) {
result[i] = data[i] * 2;
}
// Modern compilers will vectorize this automatically
}
Dependency Injection
// Bad: Hard-coded dependency
class UserManager {
Database db; // Tightly coupled
public:
void save_user(const User& user) {
db.insert(user);
}
};
// Good: Inject dependency
class UserManager {
Database& db_; // Dependency injected
public:
UserManager(Database& db) : db_(db) {}
void save_user(const User& user) {
db_.insert(user);
}
};
// Better: Use interface
class IDatabase {
public:
virtual ~IDatabase() = default;
virtual void insert(const User&) = 0;
};
class UserManager {
IDatabase& db_;
public:
UserManager(IDatabase& db) : db_(db) {}
void save_user(const User& user) {
db_.insert(user);
}
};
Prefer Composition to Inheritance
// Bad: Inheritance for reuse
class Stack : public std::vector<int> {
// Exposes vector interface (push_back, etc.)
};
// Good: Composition
class Stack {
std::vector<int> data_;
public:
void push(int value) { data_.push_back(value); }
int pop() {
int val = data_.back();
data_.pop_back();
return val;
}
// Only expose stack operations
};
Value Semantics
// Prefer value semantics
std::vector<std::string> names; // Owns strings
names.push_back("Alice"); // Makes copy/move
// Over pointer semantics
std::vector<std::string*> names; // Doesn't own
names.push_back(new std::string("Alice")); // Manual memory management
Type Safety with Strong Types
// Bad: Primitive obsession
void transfer(int from_account, int to_account, int amount);
transfer(123, 100, 456); // Easy to swap arguments!
// Good: Strong types
struct AccountId {
int value;
explicit AccountId(int v) : value(v) {}
};
struct Amount {
int value;
explicit Amount(int v) : value(v) {}
};
void transfer(AccountId from, AccountId to, Amount amount);
transfer(AccountId{123}, AccountId{456}, Amount{100}); // Type-safe!
Factory Functions
// Good: make_unique/make_shared
auto ptr = std::make_unique<Widget>(args);
auto shared = std::make_shared<Widget>(args);
// Custom factory
template<typename T, typename... Args>
auto create(Args&&... args) {
return std::make_unique<T>(std::forward<Args>(args)...);
}
auto widget = create<Widget>(arg1, arg2);
Naming Conventions
// Classes: PascalCase
class UserManager {};
// Functions/variables: snake_case
void process_data();
int user_count;
// Constants: SCREAMING_SNAKE_CASE or kPascalCase
constexpr int MAX_USERS = 100;
constexpr int kMaxUsers = 100;
// Member variables: trailing underscore
class Widget {
int value_;
std::string name_;
};
// Template parameters: PascalCase
template<typename ValueType, typename AllocatorType>
class Container {};
Documentation
/**
* @brief Computes the factorial of a number.
*
* @param n The number (must be non-negative)
* @return The factorial of n
* @throws std::invalid_argument if n < 0
*/
int factorial(int n) {
if (n < 0) {
throw std::invalid_argument("n must be non-negative");
}
// ...
}
Modern C++ Checklist
✅ Use RAII for resource management
✅ Prefer smart pointers to raw pointers
✅ Use const correctness
✅ Prefer references to pointers
✅ Initialize variables at declaration
✅ Use auto when type is obvious
✅ Prefer algorithms to raw loops
✅ Use range-based for loops
✅ Follow Rule of Zero when possible
✅ Use exceptions for exceptional cases
✅ Avoid premature optimization
✅ Prefer composition to inheritance
✅ Use strong types for type safety
✅ Document public APIs
✅ Enable compiler warnings (-Wall -Wextra)
What is the Rule of Zero?
Complete the Code
// Make this function take a non-nullable reference
void increment(int_ value) {
value += 1;
}Interactive Lab
Waiting for signal...