Expert C++
上QQ阅读APP看书,第一时间看更新

Encapsulation and the public interface

Encapsulation is a key concept in object-oriented programming. It allows us to hide the implementation details of objects from the client code. Take, for example, a computer keyboard; it has keys for letters, numbers, and symbols, each of which acts if we press on them. Its usage is simple and intuitive, and it hides a lot of low-level details that only a person familiar with electronics would be able to handle. Imagine a keyboard without keys— one that has a bare board with unlabeled pins. You would have to guess which one to press to achieve the desired key combination or text input. Now, imagine a keyboard without pins— you have to send proper signals to the corresponding sockets to get the key pressed event of a particular symbol. Users could be confused by the absence of labels and they also could use it incorrectly by pressing or sending signals to invalid sockets. The keyboard as we know it solves this issue by encapsulating the implementation details – the same way programmers encapsulate objects so that they don't load the user with redundant members and to make sure users won't use the object in the wrong way.

Visibility modifiers serve that purpose in the class by allowing us to define the accessibility level of any member. The private modifier prohibits any use of the private member from the client code. This allows us to control the modification of the private member by providing corresponding member functions. A mutator function, familiar to many as a setter function, modifies the value of a private member after testing the value against specified rules for that particular class. An example of this can be seen in the following code:

class Warehouse {
public:
// rather naive implementation
void set_size(int sz) {
if (sz < 1) throw std::invalid_argument("Invalid size");
size_ = sz;
}
// code omitted for brevity
private:
int size_;
};

Modifying a data member through a mutator function allows us to control its value. The actual data member is private, which makes it inaccessible from the client code, while the class itself provides public functions to update or read the contents of its private members. These functions, along with the constructors, are often referred to as the public interface of the class. Programmers strive to make the class' public interface user-friendly.

Take a look at the following class, which represents a quadratic equation solver: an equation of the form ax2 + bx + c = 0. One of the solutions is finding a discriminant using the formula D  = b2 - 4ac and then calculating the value of x based on the value of the discriminant (D). The following class provides five functions, that is, for setting the values of a, b, and c, respectively, to find the discriminant, and to solve and return the value of x:

class QuadraticSolver {
public:
QuadraticSolver() = default;
void set_a(double a);
void set_b(double b);
void set_c(double c);
void find_discriminant();
double solve(); // solve and return the x
private:
double a_;
double b_;
double c_;
double discriminant_;
};

The public interface includes the previously mentioned four functions and the default constructor. To solve the equation 2x2 + 5x - 8 = 0, we should use QuadraticSolver like so:

QuadraticSolver solver;
solver.set_a(2);
solver.set_b(5);
solver.set_c(-8);
solver.find_discriminant();
std::cout << "x is: " << solver.solve() << std::endl;

The public interface of the class should be designed wisely; the preceding example shows signs of bad design. The user must know the protocol, that is, the exact order to call the functions in. If the user misses the call to find_discriminant(), the result will be undefined or invalid. The public interface forces the user to learn the protocol and to call functions in the proper order, that is, setting values of a, b, and c, then calling the find_discriminant() function, and, finally, calling the solve() function to get the desired value of x. A good design should provide an intuitively easy public interface. We can overwrite QuadraticSolver so that it only has one function that takes all the necessary input values, calculates the discriminant itself, and returns the solution:

class QuadtraticSolver {
public:
QuadraticSolver() = default;
double solve(double a, double b, double c);
};

The preceding design is more intuitive than the previous one. The following code demonstrates the usage of QuadraticSolver to find the solution to the equation, 2x2 + 5x - 8 = 0:

QuadraticSolver solver;
std::cout << solver.solve(2, 5, -8) << std::endl;

The last thing to consider here is the idea that a quadratic equation can be solved in more than one way. The one we introduced is done by finding the discriminant. We should consider that, in the future, we could add further implementation methods to the class. Changing the name of the function may increase the readability of the public interface and secure the future updates to the class. We should also note that the solve() function in the preceding example takes a, b, and c as arguments, and we don't need to store them in the class since the solution is calculated directly in the function.

It's obvious that declaring an object of the QuadraticSolver just to be able to access the solve() function seems to be a redundant step. The final design of the class will look like this:

class QuadraticSolver {
public:
QuadraticSolver() = delete;

static double solve_by_discriminant(double a, double b, double c);
// other solution methods' implementations can be prefixed by "solve_by_"
};

We renamed the solve() function to solve_by_discriminant(), which also exposes the underneath method of the solution. We also made the function static, thus making it available to the user without declaring an instance of the class. However, we also marked the default constructor deleted, which, again, forces the user not to declare an object:

std::cout << QuadraticSolver::solve_by_discriminant(2, 5, -8) << std::endl;

The client code now spends less effort using the class.