Search icon CANCEL
Subscription
0
Cart icon
Your Cart (0 item)
Close icon
You have no products in your basket yet
Arrow left icon
Explore Products
Best Sellers
New Releases
Books
Videos
Audiobooks
Learning Hub
Conferences
Free Learning
Arrow right icon
Refactoring with C++
Refactoring with C++

Refactoring with C++: Explore modern ways of developing maintainable and efficient applications

Arrow left icon
Profile Icon Dmitry Danilov
Arrow right icon
Can$27.99 Can$40.99
Full star icon Full star icon Full star icon Full star icon Full star icon 5 (1 Ratings)
eBook Jul 2024 368 pages 1st Edition
eBook
Can$27.99 Can$40.99
Paperback
Can$50.99
Subscription
Free Trial
Arrow left icon
Profile Icon Dmitry Danilov
Arrow right icon
Can$27.99 Can$40.99
Full star icon Full star icon Full star icon Full star icon Full star icon 5 (1 Ratings)
eBook Jul 2024 368 pages 1st Edition
eBook
Can$27.99 Can$40.99
Paperback
Can$50.99
Subscription
Free Trial
eBook
Can$27.99 Can$40.99
Paperback
Can$50.99
Subscription
Free Trial

What do you get with eBook?

Product feature icon Instant access to your Digital eBook purchase
Product feature icon Download this book in EPUB and PDF formats
Product feature icon Access this title in our online reader with advanced features
Product feature icon DRM FREE - Read whenever, wherever and however you want
Product feature icon AI Assistant (beta) to help accelerate your learning
OR
Modal Close icon
Payment Processing...
tick Completed

Billing Address

Table of content icon View table of contents Preview book icon Preview Book

Refactoring with C++

Main Software Development Principles

In this chapter, we will explore the main software design principles that are used to create well-structured and maintainable code. One of the most important principles is SOLID, which stands for Single Responsibility Principle, Open-Closed Principle, Liskov Substitution Principle, Interface Segregation Principle, and Dependency Inversion Principle. These principles are designed to help developers create code that is easy to understand, test, and modify. We will also discuss the importance of levels of abstraction, which is the practice of breaking down complex systems into smaller, more manageable parts. Additionally, we will explore the concepts of side effects and mutability and how they can affect the overall quality of software. By understanding and applying these principles, developers can create software that is more robust, reliable, and scalable.

SOLID

SOLID is a set of principles that were first introduced by Robert C. Martin in his book Agile Software Development, Principles, Patterns, and Practices, in 2000. Robert C. Martin, also known as Uncle Bob, is a software engineer, author, and speaker. He is considered one of the most influential figures in the software development industry, known for his work on the SOLID principles and his contributions to the field of object-oriented programming. Martin has been a software developer for more than 40 years and has worked on a wide variety of projects, from small systems to large enterprise systems. He is also a well-known speaker and has given presentations on software development at many conferences and events around the world. He is an advocate of agile methodologies, and he has been influential in the development of the Agile Manifesto. The SOLID principles were developed as a way to help developers create more maintainable and scalable code by promoting good design practices. The principles were based on Martin’s experience as a software developer and his observation that many software projects suffer from poor design, which makes them difficult to understand, change, and maintain over time.

The SOLID principles are intended to be a guide for object-oriented software design, and they are based on the idea that software should be easy to understand, change, and extend over time. The principles are meant to be applied in conjunction with other software development practices, such as test-driven development and continuous integration. By following SOLID principles, developers can create code that is more robust, less prone to bugs, and easier to maintain over time.

The Single Responsibility Principle

The Single Responsibility Principle (SRP) is one of the five SOLID principles of object-oriented software design. It states that a class should have only one reason to change, meaning that a class should have only one responsibility. This principle is intended to promote code that is easy to understand, change, and test.

The idea behind the SRP is that a class should have a single, well-defined purpose. This makes it easier to understand the class’s behavior and makes it less likely that changes to the class will have unintended consequences. When a class has only one responsibility, it is also less likely to have bugs, and it is easier to write automated tests for it.

Applying the SRP can be a useful way to improve the design of a software system by making it more modular and easier to understand. By following this principle, a developer can create classes that are small, focused, and easy to reason about. This makes it easier to maintain and improve the software over time.

Let us look at a messaging system that supports multiple message types sent over the network. The system has a Message class that receives sender and receiver IDs and raw data to be sent. Additionally, it supports saving messages to the disk and sending itself via the send method:

class Message {
public:
  Message(SenderId sender_id, ReceiverId receiver_id,
          const RawData& data)
    : sender_id_{sender_id},
      receiver_id_{receiver_id}, raw_data_{data} {}
  SenderId sender_id() const { return sender_id_; }
  ReceiverId receiver_id() const { return receiver_id_; }
  void save(const std::string& file_path) const {
    // serializes a message to raw bytes and saves
    // to file system
  }
  std::string serialize() const {
    // serializes to JSON
    return {"JSON"};
  }
  void send() const {
    auto sender = Communication::get_instance();
    sender.send(sender_id_, receiver_id_, serialize());
  }
private:
  SenderId sender_id_;
  ReceiverId receiver_id_;
  RawData raw_data_;
};

The Message class is responsible for multiple concerns, such as saving messages from/to the filesystem, serializing data, sending messages, and holding the sender and receiver IDs and raw data. It would be better to separate these responsibilities into different classes or modules.

The Message class is only responsible for storing the data and serializing it to JSON format:

class Message {
public:
  Message(SenderId sender_id, ReceiverId receiver_id,
          const RawData& data)
    : sender_id_{sender_id},
      receiver_id_{receiver_id}, raw_data_{data} {}
  SenderId sender_id() const { return sender_id_; }
  ReceiverId receiver_id() const { return receiver_id_; }
  std::string serialize() const {
    // serializes to JSON
    return {"JSON"};
  }
private:
  SenderId sender_id_;
  ReceiverId receiver_id_;
  RawData raw_data_;
};

The save method can be extracted to a separate MessageSaver class, having a single responsibility:

class MessageSaver {
public:
  MessageSaver(const std::string& target_directory);
  void save(const Message& message) const;
};

And the send method is implemented in a dedicated MessageSender class. All three classes have a single and clear responsibility, and any further changes in any of them would not affect the others. This approach allows isolating the changes in the code base. It becomes crucial in a complex system requiring long compilation.

In summary, the SRP states that a class should have only one reason to change, meaning that a class should have only one responsibility. This principle is intended to promote code that is easy to understand, change, and test, and it helps in creating a more modular, maintainable, and scalable code base. By following this principle, developers can create classes that are small, focused, and easy to reason about.

Other applications of the SRP

The SRP can be applied not only to classes but also to larger components, such as applications. At the architecture level, the SRP is often implemented as microservices architecture. The idea of microservices is to build a software system as a collection of small, independent services that communicate with each other over a network rather than building it as a monolithic application. Each microservice is responsible for a specific business capability and can be developed, deployed, and scaled independently from the other services. This allows for greater flexibility, scalability, and ease of maintenance, as changes to one service do not affect the entire system. Microservices also enable a more agile development process, as teams can work on different services in parallel, and also allows for a more fine-grained approach to security, monitoring, and testing, as each service can be handled individually.

The Open-Closed Principle

The Open-Closed principle states that a module or class should be open for extension but closed for modification. In other words, it should be possible to add new functionality to a module or class without modifying its existing code. This principle helps to promote software maintainability and flexibility. An example of this principle in C++ is the use of inheritance and polymorphism. A base class can be written with the ability to be extended by derived classes, allowing for new functionality to be added without modifying the base class. Another example is using interfaces or abstract classes to define a contract for a set of related classes, allowing new classes to be added that conform to the contract without modifying existing code.

The Open-closed Principle can be used to improve our message-sending components. The current version supports only one message type. If we want to add more data, we need to change the Message class: add fields, hold a message type as an additional variable, and not to mention serialization based on this variable. In order to avoid changes in existing code, let us rewrite the Message class to be purely virtual, providing the serialize method:

class Message {
public:
  Message(SenderId sender_id, ReceiverId receiver_id)
    : sender_id_{sender_id}, receiver_id_{receiver_id} {}
  SenderId sender_id() const { return sender_id_; }
  ReceiverId receiver_id() const { return receiver_id_; }
  virtual std::string serialize() const = 0;
private:
  SenderId sender_id_;
  ReceiverId receiver_id_;
};

Now, let us assume that we need to add another two message types: a “start” message supporting start delay (often done for debugging purposes) and a “stop” message supporting stop delay (can be used for scheduling); they can be implemented as follows:

class StartMessage : public Message {
public:
  StartMessage(SenderId sender_id, ReceiverId receiver_id,
               std::chrono::milliseconds start_delay)
    : Message{sender_id, receiver_id},
      start_delay_{start_delay} {}
  std::string serialize() const override {
    return {"naive serialization to JSON"};
  }
private:
  const std::chrono::milliseconds start_delay_;
};
class StopMessage : public Message {
public:
  StopMessage(SenderId sender_id, ReceiverId receiver_id,
              std::chrono::milliseconds stop_delay)
    : Message{sender_id, receiver_id},
      stop_delay_{stop_delay} {}
  std::string serialize() const override {
    return {"naive serialization to JSON"};
  }
private:
  const std::chrono::milliseconds stop_delay_;
};

Note that none of the implementations requires changes in other classes, and each of them provides its own version of the serialize method. The MessageSender and MessageSaver classes do not need additional adjustments to support the new class hierarchy of messages. However, we are going to change them too. The main reason is to make them extendable without requiring changes. For example, a message can be saved not only to the filesystem but also to remote storage. In this case, MessageSaver becomes purely virtual:

class MessageSaver {
public:
  virtual void save(const Message& message) const = 0;
};

The implementation responsible for saving to the filesystem is a class derived from MessageSaver:

class FilesystemMessageSaver : public MessageSaver {
public:
  FilesystemMessageSaver(const std::string&
    target_directory);
  void save(const Message& message) const override;
};

And the remote storage saver is another class in the hierarchy:

class RemoteMessageSaver : public MessageSaver {
public:
    RemoteMessageSaver(const std::string&
      remote_storage_address);
    void save(const Message& message) const override;
};

The Liskov Substitution Principle

The Liskov Substitution Principle (LSP) is a fundamental principle in object-oriented programming that states that objects of a superclass should be able to be replaced with objects of a subclass without affecting the correctness of the program. This principle is also known as the Liskov principle, named after Barbara Liskov, who first formulated it. The LSP is based on the idea of inheritance and polymorphism, where a subclass can inherit the properties and methods of its parent class and can be used interchangeably with it.

In order to follow the LSP, subclasses must be “behaviorally compatible” with their parent class. This means that they should have the same method signatures and follow the same contracts, such as input and output types and ranges. Additionally, the behavior of a method in a subclass should not violate any of the contracts established in the parent class.

Let’s consider a new Message type, InternalMessage, which does not support the serialize method. One might be tempted to implement it in the following way:

class InternalMessage : public Message {
public:
    InternalMessage(SenderId sender_id, ReceiverId
      receiver_id)
        : Message{sender_id, receiver_id} {}
    std::string serialize() const override {
        throw std::runtime_error{"InternalMessage can't be
          serialized!"};
    }
};

In the preceding code, InternalMessage is a subtype of Message but cannot be serialized, throwing an exception instead. This design is problematic for a few reasons:

  • It breaks the Liskov Substitution Principle: As per the LSP, if InternalMessage is a subtype of Message, then we should be able to use InternalMessage wherever Message is expected without affecting the correctness of the program. By throwing an exception in the serialize method, we are breaking this principle.
  • The caller must handle exceptions: The caller of serialize must handle exceptions, which might not have been necessary when dealing with other Message types. This introduces additional complexity and the potential for errors in the caller code.
  • Program crashes: If the exception is not properly handled, it could lead to the program crashing, which is certainly not a desirable outcome.

We could return an empty string instead of throwing an exception, but this still violates the LSP, as the serialize method is expected to return a serialized message, not an empty string. It also introduces ambiguity, as it’s not clear whether an empty string is the result of a successful serialization of a message with no data or an unsuccessful serialization of InternalMessage.

A better approach is to separate the concerns of a Message and a SerializableMessage, where only SerializableMessages have a serialize method:

class Message {
public:
    virtual ~Message() = default;
    // other common message behaviors
};
class SerializableMessage : public Message {
public:
    virtual std::string serialize() const = 0;
};
class StartMessage : public SerializableMessage {
    // ...
};
class StopMessage : public SerializableMessage {
    // ...
};
class InternalMessage : public Message {
    // InternalMessage doesn't have serialize method now.
};

In this corrected design, the base Message class does not include a serialize method, and a new SerializableMessage class has been introduced that includes this method. This way, only messages that can be serialized will inherit from SerializableMessage, and we adhere to the LSP.

Adhering to the LSP allows for more flexible and maintainable code, as it enables the use of polymorphism and allows for substituting objects of a class with objects of its subclasses without affecting the overall behavior of the program. This way, the program can take advantage of the new functionality provided by the subclass while maintaining the same behavior as the superclass.

The Interface Segregation Principle

The Interface Segregation Principle (ISP) is a principle in object-oriented programming that states that a class should only implement the interfaces it uses. In other words, it suggests that interfaces should be fine-grained and client-specific rather than having a single, large, and all-encompassing interface. The ISP is based on the idea that it is better to have many small interfaces that each define a specific set of methods rather than a single large interface that defines many methods.

One of the key benefits of the ISP is that it promotes a more modular and flexible design, as it allows for the creation of interfaces that are tailored to the specific needs of a client. This way, it reduces the number of unnecessary methods that a client needs to implement, and also it reduces the risk of a client depending on methods that it does not need.

An example of the ISP can be observed when creating our example messages from MessagePack or JSON files. Following the best practices, we would create an interface providing two methods, from_message_pack and from_json.

The current implementations need to implement both methods, but what if a particular class does not need to support both options? The smaller the interface, the better. The MessageParser interface will be split into two separate interfaces, each requiring the implementation of either JSON or MessagePack:

class JsonMessageParser {
public:
  virtual std::unique_ptr<Message>
  parse(const std::vector<uint8_t>& message_pack)
    const = 0;
};
class MessagePackMessageParser {
public:
  virtual std::unique_ptr<Message>
  parse(const std::vector<uint8_t>& message_pack)
    const = 0;
};

This design allows for objects derived from JsonMessageParser and MessagePackMessageParser to understand how to construct themselves from JSON and MessagePack, respectively, while preserving the independence and functionality of each function. The system remains flexible as new smaller objects can still be composed to achieve the desired functionality.

Adhering to the ISP makes the code more maintainable and less prone to errors, as it reduces the number of unnecessary methods that a client needs to implement, and it also reduces the risk of a client depending on methods that it does not need.

The Dependency inversion principle

The Dependency inversion principle is based on the idea that it is better to depend on abstractions rather than on concrete implementations, as it allows for greater flexibility and maintainability. It allows the decoupling of high-level modules from low-level modules, making them more independent and less prone to changes in the low-level modules. This way, it makes it easy to change low-level implementations without affecting high-level modules and vice versa.

The DIP can be illustrated for our messaging system if we try to use all the components via another class. Let us assume that there is a class responsible for message routing. In order to build such a class, we are going to use MessageSender as a communication module, Message based classes, and MessageSaver:

class MessageRouter {
public:
  MessageRouter(ReceiverId id)
    : id_{id} {}
  void route(const Message& message) const {
    if (message.receiver_id() == id_) {
      handler_.handle(message);
    } else {
      try {
        sender_.send(message);
      } catch (const CommunicationError& e) {
        saver_.save(message);
      }
    }
  }
private:
  const ReceiverId id_;
  const MessageHandler handler_;
  const MessageSender sender_;
  const MessageSaver saver_;
};

The new class provides only one route method, which is called once a new message is available. The router handles the message to the MessageHandler class if the message’s sender ID equals the router’s. Otherwise, the router forwards the message to the corresponding receiver. In case the delivery of the message fails and the communication layer throws an exception, the router saves the message via MessageSaver. Those messages will be delivered some other time.

The only problem is that if any dependency needs to be changed, the router’s code has to be updated accordingly. For example, if the application needs to support several types of senders (TCP and UDP), the message saver (filesystem versus remote) or message handler’s logic changes. In order to make MessageRouter agnostic to such changes, we can rewrite it using the DIP principle:

class BaseMessageHandler {
public:
    virtual ~BaseMessageHandler() {}
    virtual void handle(const Message& message) const = 0;
};
class BaseMessageSender {
public:
    virtual ~BaseMessageSender() {}
    virtual void send(const Message& message) const = 0;
};
class BaseMessageSaver {
public:
    virtual ~BaseMessageSaver() {}
    virtual void save(const Message& message) const = 0;
};
class MessageRouter {
public:
    MessageRouter(ReceiverId id,
                  const BaseMessageHandler& handler,
                  const BaseMessageSender& sender,
                  const BaseMessageSaver& saver)
        : id_{id}, handler_{handler}, sender_{sender},
          saver_{saver} {}
    void route(const Message& message) const {
        if (message.receiver_id() == id_) {
            handler_.handle(message);
        } else {
            try {
                sender_.send(message);
            } catch (const CommunicationError& e) {
                saver_.save(message);
            }
        }
    }
private:
    ReceiverId id_;
    const BaseMessageHandler& handler_;
    const BaseMessageSender& sender_;
    const BaseMessageSaver& saver_;
};
int main() {
  auto id      = ReceiverId{42};
  auto handler = MessageHandler{};
  auto sender = MessageSender{
    Communication::get_instance()};
  auto saver =
    FilesystemMessageSaver{"/tmp/undelivered_messages"};
  auto router = MessageRouter{id, sender, saver};
}

In this revised version of the code, MessageRouter is now decoupled from specific implementations of the message handling, sending, and saving logic. Instead, it relies on abstractions represented by BaseMessageHandler, BaseMessageSender, and BaseMessageSaver. This way, any class that derives from these base classes can be used with MessageRouter, which makes the code more flexible and easier to extend in the future. The router is not concerned with the specifics of how messages are handled, sent, or saved – it only needs to know that these operations can be performed.

Adhering to the DIP makes code more maintainable and less prone to errors. It decouples high-level modules from low-level modules, making them more independent and less prone to changes in low-level modules. It also allows for greater flexibility, making it easy to change low-level implementations without affecting high-level modules and vice versa. Later in this book, dependency inversion will help us mock parts of the system while developing unit tests.

The KISS principle

The KISS principle, which stands for “Keep It Simple, Stupid,” is a design philosophy that emphasizes the importance of keeping things simple and straightforward. This principle is particularly relevant in the world of programming, where complex code can lead to bugs, confusion, and slow development time.

Here are some examples of how the KISS principle can be applied in C++:

  • Avoid Overcomplicating Code: In C++, it’s easy to get carried away with complex algorithms, data structures, and design patterns. However, these advanced techniques can lead to code that is harder to understand and debug. Instead, try to simplify the code as much as possible. For example, using a simple for loop instead of a complex algorithm can often be just as effective and much easier to understand.
  • Keep Functions Small: Functions in C++ should be small, focused, and easy to understand. Complex functions can quickly become difficult to maintain and debug, so try to keep functions as simple and concise as possible. A good rule of thumb is to aim for functions that are no longer than 30-50 lines of code.
  • Use Clear and Concise Variable Names: In C++, variable names play a crucial role in making code readable and understandable. Avoid using abbreviations and instead opt for clear and concise names that accurately describe the purpose of the variable.
  • Avoid Deep Nesting: Nested loops and conditional statements can make code hard to read and follow. Try to keep the nesting levels as shallow as possible, and consider breaking up complex functions into smaller, simpler functions.
  • Write Simple, Readable Code: Above all, aim to write code that is easy to understand and follow. This means using clear and concise language and avoiding complicated expressions and structures. Code that is simple and easy to follow is much more likely to be maintainable and bug-free.
  • Avoid Complex Inheritance Hierarchy: Complex inheritance hierarchies can make code more difficult to understand, debug, and maintain. The more complex the inheritance structure, the harder it becomes to keep track of the relationships between classes and determine how changes will affect the rest of the code.

In conclusion, the KISS principle is a simple and straightforward design philosophy that can help developers write clear, concise, and maintainable code. By keeping things simple, developers can avoid bugs and confusion and speed up development time.

The KISS and SOLID Principles together

The SOLID principles and the KISS principle are both important design philosophies in software development, but they can sometimes contradict each other.

The SOLID principles are a set of five principles that guide the design of software, aimed at making it more maintainable, scalable, and flexible. They focus on creating a clean, modular architecture that follows good object-oriented design practices.

The KISS principle, on the other hand, is all about keeping things simple. It advocates for straightforward, simple solutions, avoiding complex algorithms and structures that can make code hard to understand and maintain.

While both SOLID and KISS aim to improve software quality, they can sometimes be at odds. For example, following the SOLID principles may result in code that is more complex and harder to understand to achieve greater modularity and maintainability. Similarly, the KISS principle may result in less flexible and scalable code to keep it simple and straightforward.

In practice, developers often have to strike a balance between the SOLID principles and the KISS principle. On the one hand, they want to write code that is maintainable, scalable, and flexible. On the other hand, they want to write code that is simple and easy to understand. Finding this balance requires careful consideration of trade-offs and an understanding of when each approach is most appropriate.

When I have to choose between the SOLID and KISS approaches, I think about something my boss, Amir Taya, said, “When building a Ferrari, you need to start from a scooter.” This phrase is an exaggerated example of KISS: if you do not know how to build a feature, make the simplest working version (KISS), re-iterate, and extend the solution using SOLID principles if needed.

Side effects and immutability

Side effects and immutability are two important concepts in programming that have a significant impact on the quality and maintainability of code.

Side effects refer to changes that occur in the state of the program as a result of executing a particular function or piece of code. Side effects can be explicit, such as writing data to a file or updating a variable, or implicit, such as modifying the global state or causing unexpected behavior in other parts of the code.

Immutability, on the other hand, refers to the property of a variable or data structure that cannot be modified after it has been created. In functional programming, immutability is achieved by making data structures and variables constant and avoiding side effects.

The importance of avoiding side effects and using immutable variables lies in the fact that they make code easier to understand, debug, and maintain. When code has few side effects, it is easier to reason about what it does and what it does not do. This makes finding and fixing bugs and making changes to the code easier without affecting other parts of the system.

In contrast, code with many side effects is harder to understand, as the state of the program can change in unexpected ways. This makes it more difficult to debug and maintain and can lead to bugs and unexpected behavior.

Functional programming languages have long emphasized the use of immutability and the avoidance of side effects, but it is now possible to write code with these properties using C++. The easiest way to achieve it is to follow the C++ Core Guidelines for Constants and Immutability.

Con.1 – by default, make objects immutable

You can declare a built-in data type or an instance of a user-defined data type as constant, resulting in the same effect. Attempting to modify it will result in a compiler error:

struct Data {
  int val{42};
};
int main() {
  const Data data;
  data.val = 43; // assignment of member 'Data::val' in
                 // read-only object
  const int val{42};
  val = 43; // assignment of read-only variable 'val'
}

The same applies to loops:

for (const int i : array) {
  std::cout << i << std::endl; // just reading: const
}
for (int i : array) {
  std::cout << i << std::endl; // just reading: non-const
}

This approach allows the prevention of hard-to-notice changes of value.

Probably, the only exception is function parameters passed by value:

void foo(const int value);

Such parameters are rarely passed as const and rarely mutated. In order to avoid confusion, it is recommended not to enforce this rule in such cases.

Con.2 – by default, make member functions const

A member function (method) shall be marked as const unless it changes the observable state of an object. The reason behind this is to give a more precise statement of design intent, better readability, maintainability, more errors caught by the compiler, and theoretically more optimization opportunities:

class Book {
public:
  std::string name() { return name_; }
private:
  std::string name_;
};
void print(const Book& book) {
  cout << book.name()
       << endl; // ERROR: 'this' argument to member
                // function
                // 'name' has type 'const Book', but
                // function is not marked
                // const clang(member_function_call_bad_cvr)
}

There are two types of constness: physical and logical:

Physical constness: An object is declared const and cannot be changed.

Logical constness: An object is declared const but can be changed.

Logical constness can be achieved with the mutable keyword. In general, it is a rare use case. The only good example I can think of is storing in an internal cache or using a mutex:

class DataReader {
public:
  Data read() const {
    auto lock = std::lock_guard<std::mutex>(mutex);
    // read data
    return Data{};
  }
private:
  mutable std::mutex mutex;
};

In this example, we need to change the mutex variable to lock it, but this does not affect the logical constness of the object.

Please be aware that there exist legacy codes/libraries that provide functions that declare T*, despite not making any changes to the T. This presents an issue for individuals who are trying to mark all logically constant methods as const. In order to enforce constness, you can do the following:

  • Update the library/code to be const-correct, which is the preferred solution.
  • Provide a wrapper function casting away the constness.

Example

void read_data(int* data); // Legacy code: read_data does
                           // not modify `*data`
void read_data(const int* data) {
  read_data(const_cast<int*>(data));
}

Note that this solution is a patch that can be used only when the declaration of read_data cannot be modified.

Con.3 – by default, pass pointers and references to const

This one is easy; it is far easier to reason about programs when called functions do not modify state.

Let us look at the two following functions:

void foo(char* p);
void bar(const char* p);

Does the foo function modify the data the p pointer points to? We cannot answer by looking at the declaration, so we assume it does by default. However, the bar function states explicitly that the content of p will not be changed.

Con.4 – use const to define objects with values that do not change after construction

This rule is very similar to the first one, enforcing the constness of objects that are not expected to be changed in the future. It is often helpful with classes such as Config that are created at the beginning of the application and not changed during its lifetime:

class Config {
public:
  std::string hostname() const;
  uint16_t port() const;
};
int main(int argc, char* argv[]) {
  const Config config = parse_args(argc, argv);
  run(config);
}

Con.5 – use constexpr for values that can be computed at compile time

Declaring variables as constexpr is preferred over const if the value is computed at compile time. It provides such benefits as better performance, better compile-time checking, guaranteed compile-time evaluation, and no possibility of race conditions.

Constness and data races

Data races occur when multiple threads access a shared variable simultaneously, and at least one tries to modify it. There are synchronization primitives such as mutexes, critical sections, spinlocks, and semaphores, allowing the prevention of data races. The problem with these primitives is that they either do expensive system calls or overuse the CPU, which makes the code less efficient. However, if none of the threads modifies the variable, there is no place for data races. We learned that constexpr is thread-safe (does not need synchronization) because it is defined at compile time. What about const? It can be thread-safe under the below conditions.

The variable has been const since its creation. If a thread has direct or indirect (via a pointer or reference) non-const access to the variable, all the readers need to use mutexes. The following code snippet illustrates constant and non-constant access from multiple threads:

void a() {
  auto value = int{42};
  auto t = std::thread([&]() { std::cout << value; });
  t.join();
}
void b() {
  auto value = int{42};
  auto t = std::thread([&value = std::as_const(value)]() {
    std::cout << value;
  });
  t.join();
}
void c() {
  const auto value = int{42};
  auto t = std::thread([&]() {
      auto v = const_cast<int&>(value);
      std::cout << v;
  });
  t.join();
}
void d() {
  const auto value = int{42};
  auto t = std::thread([&]() { std::cout << value; });
  t.join();
}

In the a function, the value variable is owned as non-constant by both the main thread and t, which makes the code potentially not thread-safe (if a developer decides to change the value later in the main thread). In the b, the main thread has “write” access to value while t receives it via a const reference, but still, it is not thread-safe. The c function is an example of very bad code: the value is created as a constant in the main thread and passed as a const reference but then the constness is cast away, which makes this function not thread-safe. Only the d function is thread-safe because neither the main thread nor t can modify the variable.

The data type and all sub-types of the variable are either physically constant or their logical constness implementation is thread-safe. For example, in the following example, the Point struct is physically constant because its x and y field members are primitive integers, and both threads have only const access to it:

struct Point {
  int x;
  int y;
};
void foo() {
  const auto point = Point{.x = 10, .y = 10};
  auto t           = std::thread([&]() { std::cout <<
    point.x; });
  t.join();
}

The DataReader class that we saw earlier is logically constant because it has a mutable variable, mutex, but this implementation is also thread-safe (due to the lock):

class DataReader {
public:
  Data read() const {
    auto lock = std::lock_guard<std::mutex>(mutex);
    // read data
    return Data{};
  }
private:
  mutable std::mutex mutex;
};

However, let us look into the following case. The RequestProcessor class processes some heavy requests and caches the results in an internal variable:

class RequestProcessor {
public:
  Result process(uint64_t request_id,
                 Request request) const {
    if (auto it = cache_.find(request_id); it !=
      cache_.cend()) {
      return it->second;
    }
    // process request
    // create result
    auto result = Result{};
    cache_[request_id] = result;
    return result;
  }
private:
  mutable std::unordered_map<uint64_t, Result> cache_;
};
void process_request() {
  auto requests = std::vector<std::tuple<uint64_t,
    Request>>{};
  const auto processor = RequestProcessor{};
  for (const auto& request : requests) {
    auto t = std::thread([&]() {
      processor.process(std::get<0>(request),
                        std::get<1>(request));
    });
    t.detach();
  }
}

This class is logically safe, but the cache_ variable is changed in a non-thread-safe way, which makes the class non-thread-safe even when declared as const.

Note that when working with STL containers, it is essential to remember that, despite current implementations tending to be thread-safe (physically and logically), the standard provides very specific thread-safety guarantees.

All functions in a container can be called simultaneously by various threads on different containers. Broadly, functions from the C++ standard library don’t read objects accessible to other threads unless they are reachable through the function arguments, which includes the this pointer.

All const member functions are thread-safe, meaning they can be invoked simultaneously by various threads on the same container. Furthermore, the begin(), end(), rbegin(), rend(), front(), back(), data(), find(), lower_bound(), upper_bound(), equal_range(), at(), and operator[] (except in associative containers) member functions also behave as const with regard to thread safety. In other words, they can also be invoked by various threads on the same container. Broadly, C++ standard library functions won’t modify objects unless those objects are reachable, directly or indirectly, via the function’s non-const arguments, which includes the this pointer.

Different elements in the same container can be altered simultaneously by different threads, with the exception of std::vector<bool> elements. For example, a std::vector of std::future objects can receive values from multiple threads at once.

Operations on iterators, such as incrementing an iterator, read the underlying container but don’t modify it. These operations can be performed concurrently with operations on other iterators of the same container, with the const member functions, or with reads from the elements. However, operations that invalidate any iterators modify the container and must not be performed concurrently with any operations on existing iterators, even those that are not invalidated.

Elements of the same container can be altered concurrently with those member functions that don’t access these elements. Broadly, C++ standard library functions won’t read objects indirectly accessible through their arguments (including other elements of a container) except when required by its specification.

Lastly, operations on containers (as well as algorithms or other C++ standard library functions) can be internally parallelized as long as the user-visible results remain unaffected. For example, std::transform can be parallelized, but std::for_each cannot, as it is specified to visit each element of a sequence in order.

The idea of having a single mutable reference to an object became one of the pillars of the Rust programming language. This rule is in place to prevent data races, which occur when multiple threads access the same mutable data concurrently, resulting in unpredictable behavior and potential crashes. By allowing only one mutable reference to an object at a time, Rust ensures that concurrent access to the same data is properly synchronized and avoids data races.

In addition, this rule helps prevent mutable aliasing, which occurs when multiple mutable references to the same data exist simultaneously. Mutable aliasing can lead to subtle bugs and make code difficult to reason about, especially in large and complex code bases. By allowing only one mutable reference to an object, Rust avoids mutable aliasing and helps ensure that code is correct and easy to understand.

However, it’s worth noting that Rust also allows multiple immutable references to an object, which can be useful in scenarios where concurrent access is necessary but mutations are not. By allowing multiple immutable references, Rust can provide better performance and concurrency while still maintaining safety and correctness.

Summary

In this chapter, we covered the SOLID principles, the KISS principle, constness, and immutability. Let’s see what you learned!

  • SOLID principles: SOLID is a set of five principles that help us create code that’s easy to maintain, scalable, and flexible. By understanding these principles, you’re on your way to designing code that’s a dream to work with!
  • The KISS principle: The KISS principle is all about keeping things simple. By following this principle, you’ll avoid overcomplicating your code, making it easier to maintain and debug.
  • Constness: Constness is a property in C++ that makes objects read-only. By declaring objects as const, you can ensure that their values won’t be accidentally changed, making your code more stable and predictable.
  • Immutability: Immutability is all about making sure objects can’t be changed after their creation. By making objects immutable, you can avoid sneaky bugs and make your code more predictable.

With these design principles under your belt, you’re on your way to writing code that’s both robust and reliable. Happy coding!

In the next chapter, we will try to understand what causes bad code.

Left arrow icon Right arrow icon
Download code icon Download Code

Key benefits

  • Enrich your coding skills using features from the modern C++ standard and industry approved libraries
  • Implement refactoring techniques and SOLID principles in C++
  • Apply automated tools to improve your code quality
  • Purchase of the print or Kindle book includes a free PDF eBook

Description

Despite the prevalence of higher-level languages, C++ is still running the world, from bare-metal embedded systems to distributed cloud-native systems. C++ is on the frontline whenever there is a need for a performance-sensitive tool supporting complex data structures. The language has been actively evolving for the last two decades. This book is a comprehensive guide that shows you how to implement SOLID principles and refactor legacy code using the modern features and approaches of C++, the standard library, Boost library collection, and Guidelines Support Library by Microsoft. The book begins by describing the essential elements of writing clean code and discussing object-oriented programming in C++. You’ll explore the design principles of software testing with examples of using popular unit testing frameworks such as Google Test. The book also guides you through applying automated tools for static and dynamic code analysis using Clang Tools. By the end of this book, you’ll be proficient in applying industry-approved coding practices to design clean, sustainable, and readable real-world C++ code.

Who is this book for?

This book will benefit experienced C++ programmers the most, but is also suitable for technical leaders, software architects, and senior software engineers who want to save on costs and improve software development process efficiency by using modern C++ features and automated tools.

What you will learn

  • Leverage the rich type system of C++ to write safe and elegant code
  • Create advanced object-oriented designs using the unique features of C++
  • Minimize code duplication by using metaprogramming
  • Refactor code safely with the help of unit tests
  • Ensure code conventions and format with clang-format
  • Facilitate the usage of modern features automatically with clang-tidy
  • Catch complex bugs such as memory leakage and data races with Clang AddressSanitizer and ThreadSanitizer

Product Details

Country selected
Publication date, Length, Edition, Language, ISBN-13
Publication date : Jul 19, 2024
Length: 368 pages
Edition : 1st
Language : English
ISBN-13 : 9781837639410
Category :

What do you get with eBook?

Product feature icon Instant access to your Digital eBook purchase
Product feature icon Download this book in EPUB and PDF formats
Product feature icon Access this title in our online reader with advanced features
Product feature icon DRM FREE - Read whenever, wherever and however you want
Product feature icon AI Assistant (beta) to help accelerate your learning
OR
Modal Close icon
Payment Processing...
tick Completed

Billing Address

Product Details

Publication date : Jul 19, 2024
Length: 368 pages
Edition : 1st
Language : English
ISBN-13 : 9781837639410
Category :

Packt Subscriptions

See our plans and pricing
Modal Close icon
$19.99 billed monthly
Feature tick icon Unlimited access to Packt's library of 7,000+ practical books and videos
Feature tick icon Constantly refreshed with 50+ new titles a month
Feature tick icon Exclusive Early access to books as they're written
Feature tick icon Solve problems while you work with advanced search and reference features
Feature tick icon Offline reading on the mobile app
Feature tick icon Simple pricing, no contract
$199.99 billed annually
Feature tick icon Unlimited access to Packt's library of 7,000+ practical books and videos
Feature tick icon Constantly refreshed with 50+ new titles a month
Feature tick icon Exclusive Early access to books as they're written
Feature tick icon Solve problems while you work with advanced search and reference features
Feature tick icon Offline reading on the mobile app
Feature tick icon Choose a DRM-free eBook or Video every month to keep
Feature tick icon PLUS own as many other DRM-free eBooks or Videos as you like for just Can$6 each
Feature tick icon Exclusive print discounts
$279.99 billed in 18 months
Feature tick icon Unlimited access to Packt's library of 7,000+ practical books and videos
Feature tick icon Constantly refreshed with 50+ new titles a month
Feature tick icon Exclusive Early access to books as they're written
Feature tick icon Solve problems while you work with advanced search and reference features
Feature tick icon Offline reading on the mobile app
Feature tick icon Choose a DRM-free eBook or Video every month to keep
Feature tick icon PLUS own as many other DRM-free eBooks or Videos as you like for just Can$6 each
Feature tick icon Exclusive print discounts

Frequently bought together


Stars icon
Total Can$ 171.97
Refactoring with C++
Can$50.99
Modern CMake for C++
Can$63.99
Data Structures and Algorithms with the C++ STL
Can$56.99
Total Can$ 171.97 Stars icon

Table of Contents

17 Chapters
Chapter 1: Coding Standards in C++ Chevron down icon Chevron up icon
Chapter 2: Main Software Development Principles Chevron down icon Chevron up icon
Chapter 3: Causes of Bad Code Chevron down icon Chevron up icon
Chapter 4: Identifying Ideal Candidates for Rewriting – Patterns and Anti-Patterns Chevron down icon Chevron up icon
Chapter 5: The Significance of Naming Chevron down icon Chevron up icon
Chapter 6: Utilizing a Rich Static Type System in C++ Chevron down icon Chevron up icon
Chapter 7: Classes, Objects, and OOP in C++ Chevron down icon Chevron up icon
Chapter 8: Designing and Developing APIs in C++ Chevron down icon Chevron up icon
Chapter 9: Code Formatting and Naming Conventions Chevron down icon Chevron up icon
Chapter 10: Introduction to Static Analysis in C++ Chevron down icon Chevron up icon
Chapter 11: Dynamic Analysis Chevron down icon Chevron up icon
Chapter 12: Testing Chevron down icon Chevron up icon
Chapter 13: Modern Approach to Managing Third Parties Chevron down icon Chevron up icon
Chapter 14: Version Control Chevron down icon Chevron up icon
Chapter 15: Code Review Chevron down icon Chevron up icon
Index Chevron down icon Chevron up icon
Other Books You May Enjoy Chevron down icon Chevron up icon

Customer reviews

Rating distribution
Full star icon Full star icon Full star icon Full star icon Full star icon 5
(1 Ratings)
5 star 100%
4 star 0%
3 star 0%
2 star 0%
1 star 0%
Nathaniel Ryan Doromal Sep 19, 2024
Full star icon Full star icon Full star icon Full star icon Full star icon 5
Here's my review of "Refactoring with C++" by Dmitry Danilov. This book is a unique and excellent resource for C++ improvers, filling a crucial gap in the C++ literature with its focus on refactoring specifically for C++.Martin Fowler's work on refactoring was an important contribution to the software engineering literature and a definite must-read. However, it focuses on Java and heavily object-oriented patterns. I hadn't seen a detailed topic of refactoring in the C++ domain until I came across Danilov's book.C++ refactoring looks and feels quite different from Java refactoring. There are more considerations and possible ways to proceed. The patterns presented in this book are good to consider as the best patterns for refactoring in C++.If I were to offer some minor criticisms of the book, it would be that it's not entirely tailored for beginners. The difficulty of the content doesn't always increase gradually, and there are intermittent advanced topics (such as concurrency) that appear unexpectedly. However, the descriptions of best software engineering practices are well-summarized and presented in a way that even beginners can benefit from.For those learning C++, I recommend studying the examples very carefully to understand the patterns presented.
Amazon Verified review Amazon
Get free access to Packt library with over 7500+ books and video courses for 7 days!
Start Free Trial

FAQs

How do I buy and download an eBook? Chevron down icon Chevron up icon

Where there is an eBook version of a title available, you can buy it from the book details for that title. Add either the standalone eBook or the eBook and print book bundle to your shopping cart. Your eBook will show in your cart as a product on its own. After completing checkout and payment in the normal way, you will receive your receipt on the screen containing a link to a personalised PDF download file. This link will remain active for 30 days. You can download backup copies of the file by logging in to your account at any time.

If you already have Adobe reader installed, then clicking on the link will download and open the PDF file directly. If you don't, then save the PDF file on your machine and download the Reader to view it.

Please Note: Packt eBooks are non-returnable and non-refundable.

Packt eBook and Licensing When you buy an eBook from Packt Publishing, completing your purchase means you accept the terms of our licence agreement. Please read the full text of the agreement. In it we have tried to balance the need for the ebook to be usable for you the reader with our needs to protect the rights of us as Publishers and of our authors. In summary, the agreement says:

  • You may make copies of your eBook for your own use onto any machine
  • You may not pass copies of the eBook on to anyone else
How can I make a purchase on your website? Chevron down icon Chevron up icon

If you want to purchase a video course, eBook or Bundle (Print+eBook) please follow below steps:

  1. Register on our website using your email address and the password.
  2. Search for the title by name or ISBN using the search option.
  3. Select the title you want to purchase.
  4. Choose the format you wish to purchase the title in; if you order the Print Book, you get a free eBook copy of the same title. 
  5. Proceed with the checkout process (payment to be made using Credit Card, Debit Cart, or PayPal)
Where can I access support around an eBook? Chevron down icon Chevron up icon
  • If you experience a problem with using or installing Adobe Reader, the contact Adobe directly.
  • To view the errata for the book, see www.packtpub.com/support and view the pages for the title you have.
  • To view your account details or to download a new copy of the book go to www.packtpub.com/account
  • To contact us directly if a problem is not resolved, use www.packtpub.com/contact-us
What eBook formats do Packt support? Chevron down icon Chevron up icon

Our eBooks are currently available in a variety of formats such as PDF and ePubs. In the future, this may well change with trends and development in technology, but please note that our PDFs are not Adobe eBook Reader format, which has greater restrictions on security.

You will need to use Adobe Reader v9 or later in order to read Packt's PDF eBooks.

What are the benefits of eBooks? Chevron down icon Chevron up icon
  • You can get the information you need immediately
  • You can easily take them with you on a laptop
  • You can download them an unlimited number of times
  • You can print them out
  • They are copy-paste enabled
  • They are searchable
  • There is no password protection
  • They are lower price than print
  • They save resources and space
What is an eBook? Chevron down icon Chevron up icon

Packt eBooks are a complete electronic version of the print edition, available in PDF and ePub formats. Every piece of content down to the page numbering is the same. Because we save the costs of printing and shipping the book to you, we are able to offer eBooks at a lower cost than print editions.

When you have purchased an eBook, simply login to your account and click on the link in Your Download Area. We recommend you saving the file to your hard drive before opening it.

For optimal viewing of our eBooks, we recommend you download and install the free Adobe Reader version 9.