EXPLORATION 39

image

Classes and Types

One of the main design goals for C++ was to give the programmer the ability to define custom types that look and act nearly identically to the built-in types. The combination of classes and overloaded operators gives you that power. This Exploration takes a closer look at the type system and how your classes can best fit into the C++ world.

Classes vs. typedefs

Suppose you are writing a function to compute body-mass index (BMI) from an integer height in centimeters and an integer weight in kilograms. You have no difficulty writing such a function (which you can copy from your work in Explorations 28 and 34). For added clarity, you decide to add typedefs for height and weight, which allows the programmer to define variables for storing and manipulating these values with extra clarity to the human reader. Listing 39-1 shows a simple use of the compute_bmi() function and the associated typedefs.

Listing 39-1.  Computing BMI

#include <iostream>
 
typedef int height;
typedef int weight;
typedef int bmi;
 
bmi compute_bmi(height h, weight w)
{
  return w * 10000 / (h * h);
}
 
int main()
{
  std::cout << "Height in centimeters: ";
  height h{};
  std::cin >> h;
 
  std::cout << "Weight in kilograms: ";
  weight w{};
  std::cin >> w;
 
  std::cout << "Body-mass index = " << compute_bmi(w, h) << ' ';
}

Test the program. What’s wrong?

_____________________________________________________________

_____________________________________________________________

If you haven’t spotted it yet, take a closer look at the call to compute_bmi, on the last line of code in main(). Compare the arguments with the parameters in the function definition. Now do you see the problem?

In spite of the extra clarity that the height and weight typedefs offer, I still made a fundamental mistake and reversed the order of the arguments. In this case, the error is easy to spot, because the program is small. Also, the program’s output is so obviously wrong that testing quickly reveals the problem. Don’t relax too much, though; not all mistakes are so obvious.

The problem here is that a typedef does not define a new type but instead creates an alias for an existing type. The original type and its typedef alias are completely interchangeable. Thus, a height is the same as an int is the same as a weight. Because the programmer is able to mix up height and weight, the typedefs don’t actually help much.

More useful would be to create distinct types called height and weight. As distinct types, you would not be able to mix them up, and you would have full control over the operations that you allow. For example, dividing two weights should yield a plain, unitless int. Adding a height to a weight should result in an error message from the compiler. Listing 39-2 shows simple height and weight classes that impose these restrictions.

Listing 39-2  Defining Classes for height and weight

#include <iostream>
 
/// Height in centimeters
class height
{
public:
  height(int h) : value_{h} {}
  int value() const { return value_; }
private:
  int value_;
};
 
/// Weight in kilograms
class weight
{
public:
  weight(int w) : value_{w} {}
  int value() const { return value_; }
private:
  int value_;
};
 
/// Body-mass index
class bmi
{
public:
  bmi() : value_{0} {}
  bmi(height h, weight w) : value_{w.value() * 10000 / (h.value()*h.value())} {}
  int value() const { return value_; }
private:
  int value_;
};
height operator+(height a, height b)
{
  return height{a.value() + b.value()};
}
int operator/(height a, height b)
{
  return a.value() / b.value();
}
std::istream& operator>>(std::istream& in, height& h)
{
  int tmp{};
  if (in >> tmp)
    h = tmp;
  return in;
}
std::istream& operator>>(std::istream& in, weight& w)
{
  int tmp{};
  if (in >> tmp)
    w = tmp;
  return in;
}
std::ostream& operator<<(std::ostream& out, bmi i)
{
  out << i.value();
  return out;
}
// Implement other operators similarly, but implement only
// the ones that make sense.
weight operator-(weight a, weight b)
{
  return weight{a.value() - b.value()};
}
... plus other operators you want to implement ...
 
 
int main()
{
  std::cout << "Height in centimeters: ";
  height h{0};
  std::cin >> h;
 
  std::cout << "Weight in kilograms: ";
  weight w{0};
  std::cin >> w;
 
  std::cout << "Body-mass index = " << bmi(h, w) << ' ';
}

The new classes prevent mistakes, such as that in Listing 39-1, but at the expense of more code. For example, you have to write suitable I/O operators. You also have to decide which arithmetic operators to implement. And don’t forget the comparison operators. Most of these functions are trivial to write, but you can’t neglect them. In many applications, however, the work will pay off many times over, by removing potential sources of error.

I’m not suggesting that you do away with unadorned integers and other built-in types and replace them with clumsy wrapper classes. In fact, I agree with you (don’t ask how I know what you’re thinking) that the BMI example is rather artificial. If I were writing a real, honest-to-goodness program for computing and managing BMIs, I would use plain int variables and rely on careful coding and proofreading to prevent and detect errors. I use wrapper classes, such as height and weight, when they add some primary value. A big program in which heights and weights figured prominently would offer many opportunities for mistakes. In that case, I would want to use wrapper classes. I could also add some error checking to the classes, impose constraints on the domain of values they can represent, or otherwise help myself to do my job as a programmer. Nonetheless, it’s best to start simple and add complexity slowly and carefully. The next section explains in greater detail what behavior you must implement to make a useful and meaningful custom class.

Value Types

The height and weight types are examples of value types—that is, types that behave as ordinary values. Contrast them with the I/O stream types, which behave very differently. For example, you cannot copy or assign streams; you must pass them by reference to functions. Nor can you compare streams or perform arithmetic on them. Value types, by design, behave similarly to the built-in types, such as int and float. One of the important characteristics of value types is that you can store them in containers, such as vector and map. This section explains the general requirements for value types.

The basic guideline is to make sure your type behaves “like an int.” When it comes to copying, comparing, and performing arithmetic, avoid surprises, by making your custom type look, act, and work as much like the built-in types as possible.

Copying

Copying an int yields a new int that is indistinguishable from the original. Your custom type should behave the same way.

Consider the example of string. Many implementations of string are possible. Some of these use copy-on-write to optimize frequent copying and assignment. In a copy-on-write implementation, the actual string contents are kept separate from the string object. Copies of the string object do not copy the contents until and unless a copy is needed, which happens when the string contents must be modified. Many uses of strings are read-only, so copy-on-write avoids unnecessary copies of the contents, even when the string objects themselves are copied frequently.

Other implementations optimize for small strings by using the string object to store their contents but storing large strings separately. Copying small strings is fast, but copying large strings is slower. Most programs use only small strings. In spite of these differences in implementation, when you copy a string (such as passing a string by value to a function), the copy and the original are indistinguishable, just like an int.

Usually, the automatic copy constructor does what you want, and you don’t have to write any code. Nonetheless, you have to think about copying and assure yourself that the compiler’s automatic (also called implicit) copy constructor does exactly what you want.

Assigning

Assigning objects is similar to copying them. After an assignment, the target and source must contain identical values. The key difference between assignment and copying is that copying starts with a blank slate: a newly constructed object. Assignment begins with an existing object, and you may have to clean up the old value before you can assign the new value. Simple types such as height have nothing to clean up, but later in this book, you will learn how to implement more complicated types, such as string, which require careful cleanup.

Most simple types work just fine with the compiler’s implicit assignment operator, and you don’t have to write your own. Nonetheless, you must consider the possibility and make sure the implicit assignment operator is exactly what you want.

Moving

Sometimes, you don’t want to make an exact copy. I know I wrote that assignment should make an exact copy, but you can break that rule by having assignment move a value from the source to the target. The result leaves the source in an unknown state (typically empty), and the target gets the original value of the source.

Force a move assignment by calling std::move (declared in <utility>):

std::string source{"string"}, target;
target = std::move(source);

After the assignment, source is in an unknown, but valid, state. Typically it will be empty, but you cannot write code that assumes it is empty. In practical terms, the string contents of source are moved into target without copying any of the string contents. Moving is fast and independent of the amount of data stored in a container.

You can also move an object in an initializer, as follows:

std::string source{"string"};
std::string target{std::move(source)};

Moving works with strings and most containers. Consider the program in Listing 39-3.

Listing 39-3.  Copying vs. Moving

#include <iostream>
#include <utility>
#include <vector>
 
void print(std::vector<int> const& vector)
{
  std::cout << "{ ";
  for (int i : vector)
    std::cout << i << ' ';
  std::cout << "} ";
}
 
int main()
{
  std::vector<int> source{1, 2, 3 };
  print(source);
  std::vector<int> copy{source};
  print(copy);
  std::vector<int> move{std::move(source)};
  print(move);
  print(source);
}

Predict the output of the program in Listing 39-3.

_____________________________________________________________

_____________________________________________________________

_____________________________________________________________

_____________________________________________________________

When I run the program, I get the following:

{ 1 2 3 }
{ 1 2 3 }
{ 1 2 3 }
{ }

The first three lines print { 1 2 3 }, as expected. But the last line is interesting, because source was moved into move. When a vector’s contents are moved, the source becomes empty. Writing a move constructor is advanced and will have to wait until later in this book, but you can take advantage of move constructors and move assignment operators in the standard library by calling std::move().

Comparing

I defined copying and assignment in a way that requires meaningful comparison. If you can’t determine whether two objects are equal, you can’t verify whether you copied or assigned them correctly. C++ has a few ways to check whether two objects are the same.

  • The first and most obvious way is to compare objects with the == operator. Value types should overload this operator. Make sure the operator is transitive—that is, if a == b and b == c, then a == c. Make sure the operator is commutative, that is, if a == b, then b == a. Finally, the operator should be reflexive: a == a.
  • Standard algorithms such as find compare items by one of two methods: with operator== or with a caller-supplied predicate. Sometimes, you may want to compare objects with a custom predicate, for example, a person class might have operator== that compares every data member (name, address, etc.), but you want to search a container of person objects by checking only last names, which you do by writing your own comparison function. The custom predicate must obey the same transitive and reflexive restrictions as the == operator. If you are using the predicate with a specific algorithm, that algorithm calls the predicate in a particular way, so you know the order of the arguments. You don’t have to make your predicate commutative, and in some cases, you wouldn’t want to.
  • Containers such as map store their elements in sorted order. Some standard algorithms, such as binary_search, require their input range to be in sorted order. The ordered containers and algorithms use the same conventions. By default, they use the < operator, but you can also supply your own comparison predicate. These containers and algorithms never use the == operator to determine whether two objects are the same. Instead, they check for equivalence—that is, a is equivalent to b if a < b is false and b < a is false.

    If your value type can be ordered, you should overload the < operator. Ensure that the operator is transitive (if a < b and b < c, then a < c). Also, the ordering must be strict, that is, a < a is always false.

  • Containers and algorithms that check for equivalence also take an optional custom predicate instead of the < operator. The custom predicate must obey the same transitive and strictness restrictions as the < operator.

Not all types are comparable with a less-than relationship. If your type cannot be ordered, do not implement the < operator, but you must also understand that you will not be able to store objects of that type in a map or use any of the binary search algorithms. Sometimes, you may want to impose an artificial order, just to permit these uses. For example, a color type may represent colors such as red, green, or yellow. Although nothing about red or green inherently defines one as being “less than” another, you may want to define an arbitrary order, just so you can use these values as keys in a map. One immediate suggestion is to write a comparison function that compares colors as integers, using the < operator.

On the other hand, if you have a value that should be compared (such as rational), you should implement operator== and operator<. You can then implement all other comparison operators in terms of these two. (See Exploration 32 for an example of how the rational class does this.)

(If you have to store unordered objects in a map, you can use std::unordered_map, which is a new type in C++ 11. It works almost exactly the same as std::map, but it stores values in a hash table instead of a binary tree. Ensuring that a custom type can be stored in std::unordered_map is more advanced and won’t be covered until much later.)

Implement a color class that describes a color as three components: red, green, and blue, which are integers in the range 0 to 255. Define a comparison function, order_color, to permit storing colors as map keys. For extra credit, devise a suitable I/O format and overload the I/O operators too. Don’t worry about error-handling yet—for example, what if the user tries to set red to 1000, blue to 2000, and green to 3000. You’ll get to that soon enough.

Compare your solution with mine, which is presented in Listing 39-4.

Listing 39-4.  The color Class

#include <iomanip>
#include <iostream>
#include <sstream>
 
class color
{
public:
  color() : color{0, 0, 0} {}
  color(color const&) = default;
  color(int r, int g, int b) : red_{r}, green_{g}, blue_{b} {}
  int red() const { return red_; }
  int green() const { return green_; }
  int blue() const { return blue_; }
  /// Because red(), green(), and blue() are supposed to be in the range [0,255],
  /// it should be possible to add them together in a single long integer.
  /// TODO: handle out of range
  long int combined() const { return ((red() * 256L + green()) * 256) + blue(); }
private:
  int red_, green_, blue_;
};
 
inline bool operator==(color const& a, color const& b)
{
  return a.combined() == b.combined();
}
 
inline bool operator!=(color const& a, color const& b)
{
  return not (a == b);
}
 
inline bool order_color(color const& a, color const& b)
{
  return a.combined() < b.combined();
}
 
/// Write a color in HTML format: #RRGGBB.
std::ostream& operator<<(std::ostream& out, color const& c)
{
  std::ostringstream tmp{};
  // The hex manipulator tells a stream to write or read in hexadecimal (base 16).
  tmp << '#' << std::hex << std::setw(6) << std::setfill('0') << c.combined();
  out << tmp.str();
  return out;
}
 
class ioflags
{
public:
  /// Save the formatting flags from @p stream.
  ioflags(std::basic_ios<char>& stream) : stream_{stream}, flags_{stream.flags()} {}
  ioflags(ioflags const&) = delete;
  /// Restore the formatting flags.
  ~ioflags() { stream_.flags(flags_); }
private:
  std::basic_ios<char>& stream_;
  std::ios_base::fmtflags flags_;
};
 
std::istream& operator>>(std::istream& in, color& c)
{
  ioflags flags{in};
 
  char hash{};
  if (not (in >> hash))
    return in;
  if (hash != '#')
  {
    // malformed color: no leading # character
    in.unget();                 // return the character to the input stream
    in.setstate(in.failbit);    // set the failure state
    return in;
  }
  // Read the color number, which is hexadecimal: RRGGBB.
  int combined{};
  in >> std::hex >> std::noskipws;
  if (not (in >> combined))
    return in;
  // Extract the R, G, and B bytes.
  int red, green, blue;
  blue = combined % 256;
  combined = combined / 256;
  green = combined % 256;
  combined = combined / 256;
  red = combined % 256;
 
  // Assign to c only after successfully reading all the color components.
  c = color{red, green, blue};
 
  return in;
}

Listing 39-3 introduced a new trick with the ioflags class. The next section explains all.

Resource Acquisition Is Initialization

A programming idiom that goes by the name of Resource Acquisition Is Initialization (RAII) takes advantage of constructors, destructors, and automatic destruction of objects when a function returns. Briefly, the RAII idiom means that a constructor acquires a resource: it opens a file, connects to a network, or even just copies some flags from an I/O stream. The acquisition is part of the object’s initialization. The destructor releases the resource: closes the file, disconnects from the network, or restores any modified flags in the I/O stream.

To use an RAII class, all you have to do is define an object of that type. That’s all. The compiler takes care of the rest. The RAII class’s constructor takes whatever arguments it needs to acquire its resources. When the surrounding function returns, the RAII object is automatically destroyed, thereby releasing the resources. It’s that simple.

You don’t even have to wait until the function returns. Define an RAII object in a compound statement, and the object is destroyed when the statement finishes and control leaves the compound statement.

  • The ioflags class in Listing 39-4 is an example of using RAII. It throws some new items at you; let’s take them one at a time.
  • The std::basic_ios<char> class is the base class for all I/O stream classes, such as istream and ostream. Thus, ioflags works the same with input and output streams.
  • The std::ios_base::fmtflags type is the type for all the formatting flags.
  • The flags() member function with no arguments returns all the current formatting flags.
  • The flags() member function with one argument sets all the flags to its argument.

The way to use ioflags is simply to define a variable of type ioflags in a function or compound statement, passing a stream object as the sole argument to the constructor. The function can change any of the stream’s flags. In this case, the input operator sets the input radix (or base) to hexadecimal with the std::hex manipulator. The input radix is stored with the formatting flags. The operator also turns off the skipws flag. By default, this flag is enabled, which instructs the standard input operators to skip initial white space. By turning this flag off, the input operator does not permit any white space between the pound sign (#) and the color value.

When the input function returns, the ioflags object is destroyed, and its destructor restores the original formatting flags. Without the magic of RAII, the operator>> function would have to restore the flags manually at all four return points, which is burdensome and prone to error.

It makes no sense to copy an ioflags object. If you copy it, which object would be responsible for restoring the flags? Thus, the class deletes the copy constructor. If you accidentally write code that would copy the ioflags object, the compiler will complain.

RAII is a common programming idiom in C++. The more you learn about C++, the more you will come to appreciate its beauty and simplicity.

As you can see, our examples are becoming more complicated, and it’s becoming harder and harder for me to fit entire examples in a single code listing. Your next task is to understand how to separate your code into multiple files, which makes my job and yours much easier. The first step for this new task is to take a closer look at declarations, definitions, and the distinctions between them.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset