EXPLORATION 33

image

Writing Classes

The rational type is an example of a class. Now that you’ve seen a concrete example of writing your own class, it’s time to understand the general rules that govern all classes. This Exploration and the next four lay the foundation for this important aspect of C++ programming.

Anatomy of a Class

A class has a name and members—data members, member functions, and even member typedefs and nested classes. You start a class definition with the struct keyword. (You might wonder why you would not start a class definition with the class keyword. Please be patient; all will become clear in Exploration 35.) Use curly braces to surround the body of the class definition, and the definition ends with a semicolon. Within the curly braces, you list all the members. Declare data members in a manner similar to a local variable definition. You write member functions in the same manner as you would a free function. Listing 33-1 shows a simple class definition that contains only data members.

Listing 33-1.  Class Definition for a Cartesian Point

struct point
{
  double x;
  double y;
};

Listing 33-2 demonstrates how C++ lets you list multiple data members in a single declaration. Except for trivial classes, this style is uncommon. I prefer to list each member separately, so I can include a comment explaining the member, what it’s used for, what constraints apply to it, and so on. Even without the comment, a little extra clarity goes a long way.

Listing 33-2.  Multiple Data Members in One Declaration

struct point
{
  double x, y;
};

As with any other name in a C++ source file, before you can use a class name, the compiler must see its declaration or definition. You can use the name of a class within its own definition.

Use the class name as a type name, to define local variables, function parameters, function return types, and even other data members. The compiler knows about the class name from the very start of the class definition, so you can use its name as a type name inside the class definition.

When you define a variable using a class type, the compiler sets aside enough memory so the variable can store its own copy of every data member of the class. For example, define an object with type point, and the object contains the x and y members. Define another object of type point, and that object contains its own, separate x and y members.

Use the dot (.) operator to access the members, as you have been doing throughout this book. The object is the left-hand operand, and the member name is the right-hand operand, as shown in Listing 33-3.

Listing 33-3.  Using a Class and Its Members

#include <iostream>
 
struct point
{
  double x;
  double y;
};
 
int main()
{
  point origin{}, unity{};
  origin.x = 0;
  origin.y = 0;
  unity.x = 1;
  unity.y = 1;
  std::cout << "origin = (" << origin.x << ", " << origin.y << ") ";
  std::cout << "unity  = (" << unity.x  << ", " << unity.y  << ") ";
}

Member Functions

In addition to data members, you can have member functions. Member function definitions look very similar to ordinary function definitions, except you define them as part of a class definition. Also, a member function can call other member functions of the same class and can access data members of the same class. Listing 33-4 shows some member functions added to class point.

Listing 33-4.  Member Functions for Class point

#include <cmath> // for sqrt and atan2
 
struct point
{
  /// Distance to the origin.
  double distance()
  {
    return std::sqrt(x*x + y*y);
  }
  /// Angle relative to x-axis.
  double angle()
  {
    return std::atan2(y, x);
  }
 
  /// Add an offset to x and y.
  void offset(double off)
  {
    offset(off, off);
  }
  /// Add an offset to x and an offset to y
  void offset(double  xoff, double yoff)
  {
    x = x + xoff;
    y = y + yoff;
  }
 
  /// Scale x and y.
  void scale(double mult)
  {
    this->scale(mult, mult);
  }
  /// Scale x and y.
  void scale(double xmult, double ymult)
  {
    this->x = this->x * xmult;
    this->y = this->y * ymult;
  }
  double x;
  double y;
};

For each member function, the compiler generates a hidden parameter named this. When you call a member function, the compiler passes the object as the hidden argument. In a member function, you can access the object with the expression *this. The C++ syntax rules specify that the member operator (.) has higher precedence than the * operator, so you need parentheses around *this (e.g., (*this).x). As a syntactic convenience, another way to write the same expression is this->x, several examples of which you can see in Listing 33-4.

The compiler is smart enough to know when you use a member name, so the use of this-> is optional. If a name has no local definition, and it is the name of a member, the compiler assumes you want to use the member. Some programmers prefer to always include this-> for the sake of clarity—in a large program, you can easily lose track of which names are member names. Other programmers find the extra this-> to be clutter and use it only when necessary. My recommendation is the latter. You need to learn to read C++ classes, and one of the necessary skills is to be able to read a class definition, find the member names, and keep track of those names while you read the class definition.

A number of programmers employ a more subtle technique, which involves using a special prefix or suffix to denote data member names. For example, a common technique is to use the prefix m_ for all data members (“m” is short for member). Another common technique is a little less intrusive: using a plain underscore (_) suffix. I prefer a suffix to a prefix, because suffixes interfere less than prefixes, so they don’t obscure the important part of a name. From now on, I will adopt the practice of appending an underscore to every data member name.

NO LEADING UNDERSCORE

If you want to use only an underscore to denote members, use it as a suffix, not a prefix. The C++ standard sets aside certain names and prohibits you from using them. The actual rules are somewhat lengthy, because C++ inherits a number of restrictions from the C standard library. For example, you should not use any name that begins with E and is followed by a digit or an uppercase letter. (That rule seems arcane, but the C standard library defines several error code names, such as ERANGE, for a range error in a math function. This rule lets the library add new names in the future and lets those who implement libraries add vendor-specific names.)

I like simplicity, so I follow three basic rules. These rules are slightly more restrictive than the official C++ rules, but not in any burdensome way:

  • Do not use any name that contains two consecutive underscores (like__this).
  • Do not use any name that starts with an underscore (_like_this).
  • Do not use any name that is all uppercase (LIKE_THIS).

Using a reserved name results in undefined behavior. The compiler may not complain, but the results are unpredictable. Typically, a standard library implementation must invent many additional names for its internal use. By defining certain names that the application programmer cannot use, C++ ensures the library-writer can use these names within the library. If you accidentally use a name that conflicts with an internal library name, the result could be chaos or merely a subtle shift in a function’s implementation.

Constructor

As you learned in Exploration 29, a constructor is a special member function that initializes an object’s data members. You saw several variations on how to write a constructor, and now it’s time to learn a few more.

When you declare a data member, you can also provide an initializer. The initializer is a default value that the compiler uses in case a constructor does not initialize that member. Use the normal initialization syntax of providing a value or values in curly braces.

struct point {
  int x{1};
  int y{};
  point() {} // initializes x to 1 and y to 0
};

Use this style of initializing data members only when a particular member needs a single value in all or nearly all constructors. By separating the initial value from the constructor, it makes the constructor harder to read and understand. The human reader must read the constructor and the data member declarations to know how the object is initialized. On the other hand, using default initializers is a great way to ensure that data members of built-in type, such as int, are always initialized.

Recall that constructors can be overloaded, and the compiler chooses which constructor to call, based on the arguments in the initializer. I like to use curly braces to initialize an object. The values in the curly braces are passed to the constructor in the same manner as function arguments to an ordinary function. In fact, C++ 03 used parentheses to initialize an object, so an initializer looked very much like a function call. C++ 11 still allows this style of initializer, and you saw that when using an auto declaration, you must use parentheses. But in all other cases, curly braces are better. Exploration 30 demonstrated that curly braces provide greater type safety.

Another key difference with curly braces is that you can initialize a container, such as a vector, with a series of values in curly braces, as follows:

std::vector<int> data{ 1, 2, 3 };

This introduces a problem. The vector type has several constructors. For example, a two-argument constructor lets you initialize a vector with many copies of a single value. A vector with ten zeroes, for example, can be initialized as follows:

std::vector<int> ten_zeroes(10, 0);

Note that I used parentheses. What if I used curly braces? Try it. What happens?

_____________________________________________________________

_____________________________________________________________

The vector is initialized with two integers: 10 and 0. The rule is that containers treat curly braces as a series of values with which to initialize the container contents. Curly braces can be used in a few other cases, such as copying a container, but in general, you have to use parentheses to call any of the other constructors.

Write a constructor almost the same way you would an ordinary member function, but with a few differences.

  • Omit the return type.
  • Use plain return; (return statements that do not return values).
  • Use the class name as the function name.
  • Add an initializer list after a colon to initialize the data members. An initializer can also invoke another constructor, passing arguments to that constructor. Delegating construction to a common constructor is a great way to ensure rules are properly enforced by all constructors.

Listing 33-5 shows several examples of constructors added to class point.

Listing 33-5.  Constructors for Class point

struct point
{
  point()
  : point{0.0, 0.0}
  {}
  point(double x, double y)
  : x_{x}, y_{y}
  {}
  point(point const& pt)
  : point{pt.x_, pt.y_}
  {}
  double x_;
  double y_;
};

Initialization is one of the key differences between class types and built-in types. If you define an object of built-in type without an initializer, you get a garbage value, but objects of class type are always initialized by calling a constructor. You always get a chance to initialize the object’s data members. The difference between built-in types and class types are also evident in the rules C++ uses to initialize data members in a constructor.

A constructor’s initializer list is optional, but I recommend you always provide it, unless every data member has an initializer. The initializer list appears after a colon, which follows the closing parenthesis of the constructor’s parameter list; it initializes each data member in the same order in which you declare them in the class definition, ignoring the order in the initializer list. To avoid confusion, always write the initializer list in the same order as the data members. Member initializers are separated by commas and can spill onto as many lines as you need. Each member initializer provides the initial value of a single data member or uses the class name to invoke another constructor. List the member name, followed by its initializer in curly braces. Initializing a data member is the same as initializing a variable and follows the same rules.

If you don’t write any constructors for your class, the compiler writes its own default constructor. The compiler’s default constructor is just like a constructor that omits an initializer list.

struct point {
  point() {} // x_ is initialized to 0, and y_ is uninitialized
  double x_{};
  double y_;
};

When the compiler writes a constructor for you, the constructor is implicit. If you write any constructor, the compiler suppresses the implicit default constructor. If you want a default constructor in that case, you must write it yourself.

In some applications, you may want to avoid the overhead of initializing the data members of point, because your application will immediately assign a new value to the point object. Most of the time, however, caution is best.

A copy constructor is one that takes a single argument of the same type as the class, passed by reference. The compiler automatically generates calls to the copy constructor when you pass objects by value to functions, or when functions return objects. You can also initialize a point object with the value of another point object, and the compiler generates code to invoke the copy constructor.

point pt1;          // default constructor
point p2{pt1};      // copy constructor

If you don’t write your own copy constructor, the compiler writes one for you. The automatic copy constructor calls the copy constructor for every data member, just like the one in Listing 33-5. Because I wrote one that is exactly like the one the compiler writes implicitly, there is no reason to write it explicitly. Let the compiler do its job.

To help you visualize how the compiler calls constructors, read Listing 33-6. Notice how it prints a message for each constructor use.

Listing 33-6.  Visual Constructors

#include <iostream>
 
struct demo
{
  demo()      : demo{0} { std::cout << "default constructor "; }
  demo(int x) : x_{x} { std::cout << "constructor(" << x << ") "; }
  demo(demo const& that)
  : x_{that.x_}
  {
    std::cout << "copy constructor(" << x_ << ") ";
  }
  int x_;
};
 
demo addone(demo d)
{
  ++d.x_;
  return d;
}
 
int main()
{
  demo d1{};
  demo d2{d1};
  demo d3{42};
  demo d4{addone(d3)};
}

Predict the output from running the program in Listing 33-6.

_____________________________________________________________

_____________________________________________________________

_____________________________________________________________

_____________________________________________________________

_____________________________________________________________

_____________________________________________________________

_____________________________________________________________

Check your prediction. Were you correct? ________________

The compiler is allowed to perform some minor optimizations when passing arguments to functions and accepting return values. For example, instead of copying a demo object to the addone return value, and then copying the return value to initialize d4, the C++ standard permits compilers to remove unnecessary calls to the copy constructor. Not all compilers perform this optimization, and not all do so in the same manner. Most compilers require a command line switch or project option to be set before it optimizes. Thus, the exact number of calls to the copy constructor can vary slightly from one compiler or platform to another, or from one set of command line switches to another. When I run the program, I get the following:

constructor(0)
default constructor
copy constructor(0)
constructor(42)
copy constructor(42)
copy constructor(43)

Defaulted and Deleted Constructors

If you do not supply any constructors, the compiler implicitly writes a default constructor and a copy constructor. If you write at least one constructor of any variety, the compiler does not implicitly write a default constructor, but it still gives you a copy constructor if you don’t write one yourself.

You can take control over the compiler’s implicit behavior without writing any of your own constructors. Write a function header without a body for the constructor, and use =default to get the compiler’s implicit definition. Use =delete to suppress that function. For example, if you don’t want anyone creating copies of a class, note the following:

struct dont_copy
{
   dont_copy(dont_copy const&) = delete;
};

More common is letting the compiler write its copy constructor but telling the human reader explicitly. As you learn more about C++, you will learn that the rules for which constructors the compiler writes for you, and when it writes them, are more complicated than what I’ve presented so far. I urge you to get into the habit of stating when you ask the compiler to implicitly provide a constructor, even if it seems trivially obvious.

struct point
{
  point() = default;
  point(point const&) = default;
  int x, y;
};

That was easy. The next Exploration starts with a real challenge.

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

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