Home > Articles > Programming > C/C++

  • Print
  • + Share This
This chapter is from the book

18.4 Essential operations

round-b.jpg

We have now reached the point where we can discuss how to decide which constructors a class should have, whether it should have a destructor, and whether you need to provide copy and move operations. There are seven essential operations to consider:

  • Constructors from one or more arguments
  • Default constructor
  • Copy constructor (copy object of same type)
  • Copy assignment (copy object of same type)
  • Move constructor (move object of same type)
  • Move assignment (move object of same type)
  • Destructor

Usually we need one or more constructors that take arguments needed to initialize an object. For example:

string s {"cat.jpg"};                  // initialize s to the character string “cat.jpg”
Image ii {Point{200,300},"cat.jpg"};   // initialize a Point with the
                                        // coordinates{200,300},
                                        // then display the contents of file
                                        // cat.jpg at that Point

The meaning/use of an initializer is completely up to the constructor. The standard string’s constructor uses a character string as an initial value, whereas Image’s constructor uses the string as the name of a file to open. Usually we use a constructor to establish an invariant (§9.4.3). If we can’t define a good invariant for a class that its constructors can establish, we probably have a poorly designed class or a plain data structure.

Constructors that take arguments are as varied as the classes they serve. The remaining operations have more regular patterns.

How do we know if a class needs a default constructor? We need a default constructor if we want to be able to make objects of the class without specifying an initializer. The most common example is when we want to put objects of a class into a standard library vector. The following works only because we have default values for int, string, and vector<int>:

vector<double> vi(10);            // vector of 10 doubles, each initialized to 0.0
vector<string> vs(10);           // vector of 10 strings, each initialized to “”
vector<vector<int>> vvi(10);    // vector of 10 vectors, each initialized to vector{}

So, having a default constructor is often useful. The question then becomes: “When does it make sense to have a default constructor?” An answer is: “When we can establish the invariant for the class with a meaningful and obvious default value.” For value types, such as int and double, the obvious value is 0 (for ­double, that becomes 0.0). For string, the empty string, "", is the obvious choice. For ­vector, the empty vector serves well. For every type T, T{} is the default value, if a default exists. For example, double{} is 0.0, string{} is "", and vector<int>{} is the empty vector of ints.

round-g.jpg

A class needs a destructor if it acquires resources. A resource is something you “get from somewhere” and that you must give back once you have finished using it. The obvious example is memory that you get from the free store (using new) and have to give back to the free store (using delete or delete[]). Our vector acquires memory to hold its elements, so it has to give that memory back; therefore, it needs a destructor. Other resources that you might encounter as your programs increase in ambition and sophistication are files (if you open one, you also have to close it), locks, thread handles, and sockets (for communication with processes and remote computers).

round-g.jpg

Another sign that a class needs a destructor is simply that it has members that are pointers or references. If a class has a pointer or a reference member, it often needs a destructor and copy operations.

round-g.jpg

A class that needs a destructor almost always also needs a copy constructor and a copy assignment. The reason is simply that if an object has acquired a resource (and has a pointer member pointing to it), the default meaning of copy (shallow, memberwise copy) is almost certainly wrong. Again, vector is the classic example.

round-g.jpg

Similarly, a class that needs a destructor almost always also needs a move constructor and a move assignment. The reason is simply that if an object has acquired a resource (and has a pointer member pointing to it), the default meaning of copy (shallow, memberwise copy) is almost certainly wrong and the usual remedy (copy operations that duplicate the complete object state) can be expensive. Again, vector is the classic example.

round-g.jpg

In addition, a base class for which a derived class may have a destructor needs a virtual destructor (§17.5.2).

18.4.1 Explicit constructors

A constructor that takes a single argument defines a conversion from its argument type to its class. This can be most useful. For example:

class complex {
public:
     complex(double);               // defines double-to-complex conversion
     complex(double,double);
     // . . .
};
complex z1 = 3.14;                  // OK: convert 3.14 to (3.14,0)

complex z2 = complex{1.2, 3.4};
round-g.jpg

However, implicit conversions should be used sparingly and with caution, because they can cause unexpected and undesirable effects. For example, our vector, as defined so far, has a constructor that takes an int. This implies that it defines a conversion from int to vector. For example:

class vector {
     // . . .
     vector(int);
     // . . .
};
vector v = 10;                      // odd: makes a vector of 10 doubles
v = 20;                             // eh? Assigns a new vector of 20 doubles to v
void f(const vector&);
f(10);                              // eh? Calls f with a new vector of 10 doubles
round-b.jpg

It seems we are getting more than we have bargained for. Fortunately, it is simple to suppress this use of a constructor as an implicit conversion. A constructor-defined explicit provides only the usual construction semantics and not the implicit conversions. For example:

class vector {
     // . . .
     explicit vector(int);
     // . . .
};
vector v = 10;                     // error: no int-to-vector conversion
v = 20;                            // error: no int-to-vector conversion
vector v0(10);                     // OK
void f(const vector&);
f(10);                             // error: no int-to-vector<double> conversion
f(vector(10));                     // OK

To avoid surprising conversions, we — and the standard — define vector’s single-argument constructors to be explicit. It’s a pity that constructors are not explicit by default; if in doubt, make any constructor that can be invoked with a single argument explicit.

18.4.2 Debugging constructors and destructors

round-g.jpg

Constructors and destructors are invoked at well-defined and predictable points of a program’s execution. However, we don’t always write explicit calls, such as vector(2); rather we do something, such as declaring a vector, passing a vector as a by-value argument, or creating a vector on the free store using new. This can cause confusion for people who think in terms of syntax. There is not just a single syntax that triggers a constructor. It is simpler to think of constructors and destructors this way:

  • Whenever an object of type X is created, one of X’s constructors is invoked.
  • Whenever an object of type X is destroyed, X’s destructor is invoked.

A destructor is called whenever an object of its class is destroyed; that happens when names go out of scope, the program terminates, or delete is used on a pointer to an object. A constructor (some appropriate constructor) is invoked whenever an object of its class is created; that happens when a variable is initialized, when an object is created using new (except for built-in types), and whenever an object is copied.

But when does that happen? A good way to get a feel for that is to add print statements to constructors, assignment operations, and destructors and then just try. For example:

struct X {                    // simple test class
   int val;
      void out(const string& s, int nv)
           { cerr << this << ">" << s << ": " << val << " (" << nv << ")\n"; }
      X(){ out("X()",0); val=0; }                            // default constructor
      X(int v) { val=v; out( "X(int)",v); }
      X(const X& x){ val=x.val; out("X(X&) ",x.val); }        // copy constructor
      X& operator=(const X& a)                               // copy assignment
            { out("X::operator=()",a.val); val=a.val; return *this; }
      ~X() { out("~X()",0); }                                // destructor
};

Anything we do with this X will leave a trace that we can study. For example:

X glob(2);                    // a global variable
X copy(X a) { return a; }
X copy2(X a) { X aa = a; return aa; }
X& ref_to(X& a) { return a; }
X* make(int i) { X a(i); return new X(a); }
struct XX { X a; X b; };
int main()
{
      X loc {4};           // local variable
      X loc2 {loc};        // copy construction
      loc = X{5};          // copy assignment
      loc2 = copy(loc);    // call by value and return
      loc2 = copy2(loc);
      X loc3 {6};
      X& r = ref_to(loc);   // call by reference and return
      delete make(7);
      delete make(8);
      vector<X> v(4);     // default values
      XX loc4;
      X* p = new X{9};     // an X on the free store
      delete p;
      X* pp = new X[5];   // an array of Xs on the free store
      delete[] pp;
}

Try executing that.

round-g.jpg

Depending on the quality of your compiler, you may note some “missing copies” relating to our calls of copy() and copy2(). We (humans) can see that those functions do nothing: they just copy a value unmodified from input to output. If a compiler is smart enough to notice that, it is allowed to eliminate the calls to the copy constructor. In other words, a compiler is allowed to assume that a copy constructor copies and does nothing but copy. Some compilers are smart enough to eliminate many spurious copies. However, compilers are not guaranteed to be that smart, so if you want portable performance, consider move operations (§18.3.4).

Now consider: Why should we bother with this “silly class X”? It’s a bit like the finger exercises that musicians have to do. After doing them, other things — things that matter — become easier. Also, if you have problems with constructors and destructors, you can insert such print statements in constructors for your real classes to see that they work as intended. For larger programs, this exact kind of tracing becomes tedious, but similar techniques apply. For example, you can determine whether you have a memory leak by seeing if the number of constructions minus the number of destructions equals zero. Forgetting to define copy constructors and copy assignments for classes that allocate memory or hold pointers to objects is a common — and easily avoidable — source of problems.

round-g.jpg

If your problems get too big to handle by such simple means, you will have learned enough to be able to start using the professional tools for finding such problems; they are often referred to as “leak detectors.” The ideal, of course, is not to leak memory by using techniques that avoid such leaks.

  • + Share This
  • 🔖 Save To Your Account