Mastering Modern C++: A 5-Minute Guide to Safer Code

In this article, we'll explore what can be called introductory Advanced C++, focusing on the simplest yet most crucial parts of modern C++ that you absolutely need to know for professional development.

The Shift to Safer C++

To produce modern, safe, and effective code, two key components are essential: the vector and the unique_pointer. Recently, there has been a significant trend towards learning Rust, often positioned as the sole remedy for problematic C++ code. However, many of the issues attributed to C++ stem from outdated practices.

Many developers, especially those new to the language, tend to write what is essentially "C with classes." They might use classes, inheritance, and polymorphism, but they carry over unsafe C habits like manual memory management with malloc or new and raw pointers. If this sounds familiar, this guide will show you a better way.

You don't need to switch to a new language to achieve memory safety. The modern C++ standard library offers powerful tools that provide protections similar to those found in languages like Rust. We will focus on writing safe code without manually allocating and freeing memory, which is a source of numerous defects. This is achieved through smart pointer types provided by the standard library, namely unique_pointer and shared_pointer.

Demystifying the vector

Let's clarify a common point of confusion: in C++, the dynamic array class is named vector. This naming can be misleading, suggesting complex matrix math, but it's simply a dynamic array that grows and shrinks on demand. So, whenever you see vector, just think "array."

Using modern C++ features like vector and unique_pointer helps create more correct code and reduce memory problems by providing better memory management, increased safety, and improved performance. These features simplify resource management and make the code more readable, robust, and maintainable.

A Note on Namespaces

Another hurdle for newcomers is namespaces. The C++ standard library is packaged in the std namespace, leading to code peppered with std::vector and std::string. For simplicity in this article's examples, we'll use using namespace std; at the top of our source file.

Note: While this simplifies the code for learning, it's generally not recommended in larger projects due to potential naming conflicts.

Let's take a brief look at each component, and then we'll dive into the editor and explore them in a few examples.

The Power of std::vector

vector is a dynamic array provided by the C++ standard library. It manages memory internally, allowing it to grow and shrink as needed whenever you add or remove elements. This alleviates the need for manual memory allocation and deallocation, reducing the risk of memory leaks and other related issues.

Using a vector has several advantages: * Automatic Memory Management: It handles all memory allocation and deallocation, preventing memory leaks and ensuring that the allocated memory is released when the vector goes out of scope. You never allocate or free objects directly; you let the vector do it for you. * Bounds Checking: The vector provides an at() function that offers bounds checking, helping you to prevent buffer overflows and other out-of-bounds access errors. * Iterator Support: The vector supports iterators, which makes it easier to work with standard library algorithms and range-based for loops. You can create a for loop that just iterates over every element in a vector without worrying about indexes and bounds. * Dynamic Resizing: The vector grows and shrinks as elements are added or removed, without the need for manual sizing.

Understanding std::unique_ptr

unique_pointer is a smart pointer that represents unique ownership of a dynamically allocated object. It automatically deletes the object when the unique_pointer itself goes out of scope.

Using unique_pointer has several advantages: * Ownership Semantics: It enforces a clear ownership policy, ensuring that there is only one owner of the allocated memory at any given time. This avoids issues such as double deletion, memory leaks, and dangling pointers. * Automatic Memory Management: It handles memory allocation and deallocation automatically, releasing the object's memory when the unique_pointer goes out of scope or is explicitly reset. * Custom Deleters: It supports custom deleters, which allows for more fine-grained control over how the managed object is deleted. For example, with ESP32 development, you can use a special allocator for external PSRAM without manually managing it. * Move Semantics: It supports move semantics, which allows for the efficient transfer of ownership without copying the managed object. This leads to much more efficient code.

A Practical Example: Blackjack in C++

To illustrate these concepts, let's examine the core logic for a game of Blackjack. This code will demonstrate how to manage a deck of cards and player hands using modern C++ features. We'll start with the program's main function to see the overall logic.

// main function
int main() {
    Deck deck;
    deck.shuffle();

    Player player("Player");
    Player dealer("Dealer");

    player.addCard(deck.drawCard());
    player.addCard(deck.drawCard());

    dealer.addCard(deck.drawCard());
    dealer.addCard(deck.drawCard());

    // Display hand values to verify
    std::cout << player.getName() << "'s hand value: " << player.getHandValue() << std::endl;
    std::cout << dealer.getName() << "'s hand value: " << dealer.getHandValue() << std::endl;

    return 0;
}

The logic is straightforward: a deck is created and shuffled. Two players, the "player" and the "dealer," are created. Each is dealt two cards from the deck. Finally, their hand values are displayed.

Now, let's look at the classes that make this work.

// Card, Rank, and Suit definitions
enum Rank { ACE = 1, TWO, THREE, FOUR, FIVE, SIX, SEVEN, EIGHT, NINE, TEN, JACK, QUEEN, KING };
enum Suit { HEARTS, DIAMONDS, CLUBS, SPADES };

class Card {
public:
    Card(Rank r, Suit s) : rank(r), suit(s) {}
    Rank getRank() const { return rank; }
    Suit getSuit() const { return suit; }
private:
    Rank rank;
    Suit suit;
};

At the top of the file, we define the Rank and Suit of the cards using enumerations. The Card class itself is simple; it just holds a rank and a suit.

The Deck class is where we see the modern C++ features in action.

class Deck {
public:
    Deck() {
        for (int s = HEARTS; s <= SPADES; ++s) {
            for (int r = ACE; r <= KING; ++r) {
                cards.push_back(std::make_unique<Card>(static_cast<Rank>(r), static_cast<Suit>(s)));
            }
        }
    }

    void shuffle() {
        std::random_device rd;
        std::mt19937 g(rd());
        std::shuffle(cards.begin(), cards.end(), g);
    }

    std::unique_ptr<Card> drawCard() {
        if (cards.empty()) {
            return nullptr;
        }
        std::unique_ptr<Card> card = std::move(cards.back());
        cards.pop_back();
        return card;
    }

private:
    std::vector<std::unique_ptr<Card>> cards;
};

The Deck constructor creates all 52 cards. Crucially, it doesn't create Card objects directly but unique_ptr<Card>. The cards member is a vector of these unique pointers. When the Deck object is destroyed, its vector is cleaned up, which in turn destroys each unique_ptr, automatically freeing the memory for each Card object. This is the power of ownership semantics.

Notice the use of std::make_unique. This function dynamically constructs a Card and returns a unique_pointer to it. This is the modern, safe way to create objects on the heap.

A unique_pointer is unique; it cannot be copied. This ensures there is always a single owner for any given object. All 52 cards begin as unique pointers stored in the cards vector.

The shuffle function uses the standard library's std::shuffle algorithm with a modern random number engine (mt19937) to randomize the deck.

The drawCard function demonstrates move semantics. Since a unique_pointer cannot be copied, ownership is moved from the deck's vector to the caller. The std::move call facilitates this transfer. If the deck is empty, it returns a nullptr. Even if we forgot to call pop_back(), there would be no memory leak, just a null pointer left in the vector—a logic bug, not a memory corruption issue.

Finally, the Player class also uses a vector of unique pointers to hold its cards.

class Player {
public:
    Player(const std::string& n) : name(n) {}

    void addCard(std::unique_ptr<Card> card) {
        if (card) {
            hand.push_back(std::move(card));
        }
    }

    int getHandValue() const {
        int value = 0;
        int aces = 0;
        for (const auto& card : hand) {
            if (card->getRank() >= TEN) {
                value += 10;
            } else if (card->getRank() == ACE) {
                aces++;
                value += 11;
            } else {
                value += card->getRank();
            }
        }
        while (value > 21 && aces > 0) {
            value -= 10;
            aces--;
        }
        return value;
    }

    std::string getName() const { return name; }

private:
    std::string name;
    std::vector<std::unique_ptr<Card>> hand;
};

When a card is added to a player's hand, it is moved, not copied. This ensures that every card object created is accounted for and exists in only one place: the deck or a player's hand.

Conclusion: A Safer Path Forward

Returning to our main function, we can see the complete picture. The deck is created and shuffled. Cards are moved from the deck to the player and dealer hands. At any point, the deck contains 48 card pointers, and each player has two. Every card is accounted for.

By using vector and unique_pointer, you can write code that is effectively impervious to a whole class of memory management bugs. Even when logic bugs are introduced, they are far less likely to cause crashes, memory corruption, or other common C-style issues. You can focus on fixing the logic, knowing the code's foundation is solid.