IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

Thinking in C++ - Volume 1


précédentsommairesuivant

6. Initialization & Cleanup

Chapter 4 made a significant improvement in library
use by taking all the scattered components of a typical
C library and encapsulating them into a structure (an abstract data type, called a class from now on).

This not only provides a single unified point of entry into a library component, but it also hides the names of the functions within the class name. In Chapter 5, access control (implementation hiding) was introduced. This gives the class designer a way to establish clear boundaries for determining what the client programmer is allowed to manipulate and what is off limits. It means the internal mechanisms of a data type's operation are under the control and discretion of the class designer, and it's clear to client programmers what members they can and should pay attention to.

Together, encapsulation and access control make a significant step in improving the ease of library use. The concept of “new data type” they provide is better in some ways than the existing built-in data types from C. The C++ compiler can now provide type-checking guarantees for that data type and thus ensure a level of safety when that data type is being used.

When it comes to safety, however, there's a lot more the compiler can do for us than C provides. In this and future chapters, you'll see additional features that have been engineered into C++ that make the bugs in your program almost leap out and grab you, sometimes before you even compile the program, but usually in the form of compiler warnings and errors. For this reason, you will soon get used to the unlikely-sounding scenario that a C++ program that compiles often runs right the first time.

Two of these safety issues are initialization and cleanup. A large segment of C bugs occur when the programmer forgets to initialize or clean up a variable. This is especially true with C libraries, when client programmers don't know how to initialize a struct, or even that they must. (Libraries often do not include an initialization function, so the client programmer is forced to initialize the struct by hand.) Cleanup is a special problem because C programmers are comfortable with forgetting about variables once they are finished, so any cleaning up that may be necessary for a library's struct is often missed.

In C++, the concept of initialization and cleanup is essential for easy library use and to eliminate the many subtle bugs that occur when the client programmer forgets to perform these activities. This chapter examines the features in C++ that help guarantee proper initialization and cleanup.

6-1. Guaranteed initialization with the constructor

Both the Stash and Stack classes defined previously have a function called initialize( ), which hints by its name that it should be called before using the object in any other way. Unfortunately, this means the client programmer must ensure proper initialization. Client programmers are prone to miss details like initialization in their headlong rush to make your amazing library solve their problem. In C++, initialization is too important to leave to the client programmer. The class designer can guarantee initialization of every object by providing a special function called the constructor. If a class has a constructor, the compiler automatically calls that constructor at the point an object is created, before client programmers can get their hands on the object. The constructor call isn't even an option for the client programmer; it is performed by the compiler at the point the object is defined.

The next challenge is what to name this function. There are two issues. The first is that any name you use is something that can potentially clash with a name you might like to use as a member in the class. The second is that because the compiler is responsible for calling the constructor, it must always know which function to call. The solution Stroustrup chose seems the easiest and most logical: the name of the constructor is the same as the name of the class. It makes sense that such a function will be called automatically on initialization.

Here's a simple class with a constructor:

 
Sélectionnez
class X {
  int i;
public:
  X();  // Constructor
};

Now, when an object is defined,

 
Sélectionnez
void f() {
  X a;
  // ...
}

the same thing happens as if a were an int: storage is allocated for the object. But when the program reaches the sequence point (point of execution) where a is defined, the constructor is called automatically. That is, the compiler quietly inserts the call to X::X( ) for the object a at the point of definition. Like any member function, the first (secret) argument to the constructor is the this pointer - the address of the object for which it is being called. In the case of the constructor, however, this is pointing to an un-initialized block of memory, and it's the job of the constructor to initialize this memory properly.

Like any function, the constructor can have arguments to allow you to specify how an object is created, give it initialization values, and so on. Constructor arguments provide you with a way to guarantee that all parts of your object are initialized to appropriate values. For example, if a class Tree has a constructor that takes a single integer argument denoting the height of the tree, then you must create a tree object like this:

 
Sélectionnez
Tree t(12);  // 12-foot tree

If Tree(int) is your only constructor, the compiler won't let you create an object any other way. (We'll look at multiple constructors and different ways to call constructors in the next chapter.)

That's really all there is to a constructor; it's a specially named function that is called automatically by the compiler for every object at the point of that object's creation. Despite it's simplicity, it is exceptionally valuable because it eliminates a large class of problems and makes the code easier to write and read. In the preceding code fragment, for example, you don't see an explicit function call to some initialize( ) function that is conceptually separate from definition. In C++, definition and initialization are unified concepts - you can't have one without the other.

Both the constructor and destructor are very unusual types of functions: they have no return value. This is distinctly different from a void return value, in which the function returns nothing but you still have the option to make it something else. Constructors and destructors return nothing and you don't have an option. The acts of bringing an object into and out of the program are special, like birth and death, and the compiler always makes the function calls itself, to make sure they happen. If there were a return value, and if you could select your own, the compiler would somehow have to know what to do with the return value, or the client programmer would have to explicitly call constructors and destructors, which would eliminate their safety.

6-2. Guaranteed cleanup with the destructor

As a C programmer, you often think about the importance of initialization, but it's rarer to think about cleanup. After all, what do you need to do to clean up an int? Just forget about it. However, with libraries, just “letting go” of an object once you're done with it is not so safe. What if it modifies some piece of hardware, or puts something on the screen, or allocates storage on the heap? If you just forget about it, your object never achieves closure upon its exit from this world. In C++, cleanup is as important as initialization and is therefore guaranteed with the destructor.

The syntax for the destructor is similar to that for the constructor: the class name is used for the name of the function. However, the destructor is distinguished from the constructor by a leading tilde (~). In addition, the destructor never has any arguments because destruction never needs any options. Here's the declaration for a destructor:

 
Sélectionnez
class Y {
public:
  ~Y();
};

The destructor is called automatically by the compiler when the object goes out of scope. You can see where the constructor gets called by the point of definition of the object, but the only evidence for a destructor call is the closing brace of the scope that surrounds the object. Yet the destructor is still called, even when you use goto to jump out of a scope. (goto still exists in C++ for backward compatibility with C and for the times when it comes in handy.) You should note that a nonlocal goto, implemented by the Standard C library functions setjmp( ) and longjmp( ), doesn't cause destructors to be called. (This is the specification, even if your compiler doesn't implement it that way. Relying on a feature that isn't in the specification means your code is nonportable.)

Here's an example demonstrating the features of constructors and destructors you've seen so far:

 
Sélectionnez
//: C06:Constructor1.cpp
// Constructors & destructors
#include <iostream>
using namespace std;
 
class Tree {
  int height;
public:
  Tree(int initialHeight);  // Constructor
  ~Tree();  // Destructor
  void grow(int years);
  void printsize();
};
 
Tree::Tree(int initialHeight) {
  height = initialHeight;
}
 
Tree::~Tree() {
  cout << "inside Tree destructor" << endl;
  printsize();
}
 
void Tree::grow(int years) {
  height += years;
}
 
void Tree::printsize() {
  cout << "Tree height is " << height << endl;
}
 
int main() {
  cout << "before opening brace" << endl;
  {
    Tree t(12);
    cout << "after Tree creation" << endl;
    t.printsize();
    t.grow(4);
    cout << "before closing brace" << endl;
  }
  cout << "after closing brace" << endl;
} ///:~

Here's the output of the above program:

 
Sélectionnez
before opening brace
after Tree creation
Tree height is 12
before closing brace
inside Tree destructor
Tree height is 16
after closing brace

You can see that the destructor is automatically called at the closing brace of the scope that encloses it.

6-3. Elimination of the definition block

In C, you must always define all the variables at the beginning of a block, after the opening brace. This is not an uncommon requirement in programming languages, and the reason given has often been that it's “good programming style.” On this point, I have my suspicions. It has always seemed inconvenient to me, as a programmer, to pop back to the beginning of a block every time I need a new variable. I also find code more readable when the variable definition is close to its point of use.

Perhaps these arguments are stylistic. In C++, however, there's a significant problem in being forced to define all objects at the beginning of a scope. If a constructor exists, it must be called when the object is created. However, if the constructor takes one or more initialization arguments, how do you know you will have that initialization information at the beginning of a scope? In the general programming situation, you won't. Because C has no concept of private, this separation of definition and initialization is no problem. However, C++ guarantees that when an object is created, it is simultaneously initialized. This ensures that you will have no uninitialized objects running around in your system. C doesn't care; in fact, C encourages this practice by requiring you to define variables at the beginning of a block before you necessarily have the initialization information(38).

In general, C++ will not allow you to create an object before you have the initialization information for the constructor. Because of this, the language wouldn't be feasible if you had to define variables at the beginning of a scope. In fact, the style of the language seems to encourage the definition of an object as close to its point of use as possible. In C++, any rule that applies to an “object” automatically refers to an object of a built-in type as well. This means that any class object or variable of a built-in type can also be defined at any point in a scope. It also means that you can wait until you have the information for a variable before defining it, so you can always define and initialize at the same time:

 
Sélectionnez
//: C06:DefineInitialize.cpp
// Defining variables anywhere
#include "../require.h"
#include <iostream>
#include <string>
using namespace std;
 
class G {
  int i;
public:
  G(int ii);
};
 
G::G(int ii) { i = ii; }
 
int main() {
  cout << "initialization value? ";
  int retval = 0;
  cin >> retval;
  require(retval != 0);
  int y = retval + 3;
  G g(y);
} ///:~

You can see that some code is executed, then retval is defined, initialized, and used to capture user input, and then y and g are defined. C, on the other hand, does not allow a variable to be defined anywhere except at the beginning of the scope.

In general, you should define variables as close to their point of use as possible, and always initialize them when they are defined. (This is a stylistic suggestion for built-in types, where initialization is optional.) This is a safety issue. By reducing the duration of the variable's availability within the scope, you are reducing the chance it will be misused in some other part of the scope. In addition, readability is improved because the reader doesn't have to jump back and forth to the beginning of the scope to know the type of a variable.

6-3-1. for loops

In C++, you will often see a for loop counter defined right inside the for expression:

 
Sélectionnez
for(int j = 0; j < 100; j++) {
    cout << "j = " << j << endl;
}
for(int i = 0; i < 100; i++)
 cout << "i = " << i << endl;

The statements above are important special cases, which cause confusion to new C++ programmers.

The variables i and j are defined directly inside the for expression (which you cannot do in C). They are then available for use in the for loop. It's a very convenient syntax because the context removes all question about the purpose of i and j, so you don't need to use such ungainly names as i_loop_counter for clarity.

However, some confusion may result if you expect the lifetimes of the variables i and j to extend beyond the scope of the for loop - they do not(39).

Chapter 3 points out that while and switch statements also allow the definition of objects in their control expressions, although this usage seems far less important than with the for loop.

Watch out for local variables that hide variables from the enclosing scope. In general, using the same name for a nested variable and a variable that is global to that scope is confusing and error prone(40).

I find small scopes an indicator of good design. If you have several pages for a single function, perhaps you're trying to do too much with that function. More granular functions are not only more useful, but it's also easier to find bugs.

6-3-2. Storage allocation

A variable can now be defined at any point in a scope, so it might seem that the storage for a variable may not be defined until its point of definition. It's actually more likely that the compiler will follow the practice in C of allocating all the storage for a scope at the opening brace of that scope. It doesn't matter because, as a programmer, you can't access the storage (a.k.a. the object) until it has been defined(41). Although the storage is allocated at the beginning of the block, the constructor call doesn't happen until the sequence point where the object is defined because the identifier isn't available until then. The compiler even checks to make sure that you don't put the object definition (and thus the constructor call) where the sequence point only conditionally passes through it, such as in a switch statement or somewhere a goto can jump past it. Uncommenting the statements in the following code will generate a warning or an error:

 
Sélectionnez
//: C06:Nojump.cpp
// Can't jump past constructors
 
class X {
public:
  X();
};
 
X::X() {}
 
void f(int i) {
  if(i < 10) {
   //! goto jump1; // Error: goto bypasses init
  }
  X x1;  // Constructor called here
 jump1:
  switch(i) {
    case 1 :
      X x2;  // Constructor called here
      break;
  //! case 2 : // Error: case bypasses init
      X x3;  // Constructor called here
      break;
  }
} 
 
int main() {
  f(9);
  f(11);
}///:~

In the code above, both the goto and the switch can potentially jump past the sequence point where a constructor is called. That object will then be in scope even if the constructor hasn't been called, so the compiler gives an error message. This once again guarantees that an object cannot be created unless it is also initialized.

All the storage allocation discussed here happens, of course, on the stack. The storage is allocated by the compiler by moving the stack pointer “down” (a relative term, which may indicate an increase or decrease of the actual stack pointer value, depending on your machine). Objects can also be allocated on the heap using new, which is something we'll explore further in Chapter 13.

6-4. Stash with constructors and destructors

The examples from previous chapters have obvious functions that map to constructors and destructors: initialize( ) and cleanup( ). Here's the Stash header using constructors and destructors:

 
Sélectionnez
//: C06:Stash2.h
// With constructors & destructors
#ifndef STASH2_H
#define STASH2_H
 
class Stash {
  int size;      // Size of each space
  int quantity;  // Number of storage spaces
  int next;      // Next empty space
  // Dynamically allocated array of bytes:
  unsigned char* storage;
  void inflate(int increase);
public:
  Stash(int size);
  ~Stash();
  int add(void* element);
  void* fetch(int index);
  int count();
};
#endif // STASH2_H ///:~

The only member function definitions that are changed are initialize( ) and cleanup( ), which have been replaced with a constructor and destructor:

 
Sélectionnez
//: C06:Stash2.cpp {O}
// Constructors & destructors
#include "Stash2.h"
#include "../require.h"
#include <iostream>
#include <cassert>
using namespace std;
const int increment = 100;
 
Stash::Stash(int sz) {
  size = sz;
  quantity = 0;
  storage = 0;
  next = 0;
}
 
int Stash::add(void* element) {
  if(next >= quantity) // Enough space left?
    inflate(increment);
  // Copy element into storage,
  // starting at next empty space:
  int startBytes = next * size;
  unsigned char* e = (unsigned char*)element;
  for(int i = 0; i < size; i++)
    storage[startBytes + i] = e[i];
  next++;
  return(next - 1); // Index number
}
 
void* Stash::fetch(int index) {
  require(0 <= index, "Stash::fetch (-)index");
  if(index >= next)
    return 0; // To indicate the end
  // Produce pointer to desired element:
  return &(storage[index * size]);
}
 
int Stash::count() {
  return next; // Number of elements in CStash
}
 
void Stash::inflate(int increase) {
  require(increase > 0, 
    "Stash::inflate zero or negative increase");
  int newQuantity = quantity + increase;
  int newBytes = newQuantity * size;
  int oldBytes = quantity * size;
  unsigned char* b = new unsigned char[newBytes];
  for(int i = 0; i < oldBytes; i++)
    b[i] = storage[i]; // Copy old to new
  delete [](storage); // Old storage
  storage = b; // Point to new memory
  quantity = newQuantity;
}
 
Stash::~Stash() {
  if(storage != 0) {
   cout << "freeing storage" << endl;
   delete []storage;
  }
} ///:~

You can see that the require.h functions are being used to watch for programmer errors, instead of assert( ). The output of a failed assert( ) is not as useful as that of the require.h functions (which will be shown later in the book).

Because inflate( ) is private, the only way a require( ) could fail is if one of the other member functions accidentally passed an incorrect value to inflate( ). If you are certain this can't happen, you could consider removing the require( ), but you might keep in mind that until the class is stable, there's always the possibility that new code might be added to the class that could cause errors. The cost of the require( ) is low (and could be automatically removed using the preprocessor) and the value of code robustness is high.

Notice in the following test program how the definitions for Stash objects appear right before they are needed, and how the initialization appears as part of the definition, in the constructor argument list:

 
Sélectionnez
//: C06:Stash2Test.cpp
//{L} Stash2
// Constructors & destructors
#include "Stash2.h"
#include "../require.h"
#include <fstream>
#include <iostream>
#include <string>
using namespace std;
 
int main() {
  Stash intStash(sizeof(int));
  for(int i = 0; i < 100; i++)
    intStash.add(&i);
  for(int j = 0; j < intStash.count(); j++)
    cout << "intStash.fetch(" << j << ") = "
         << *(int*)intStash.fetch(j)
         << endl;
  const int bufsize = 80;
  Stash stringStash(sizeof(char) * bufsize);
  ifstream in("Stash2Test.cpp");
  assure(in, " Stash2Test.cpp");
  string line;
  while(getline(in, line))
    stringStash.add((char*)line.c_str());
  int k = 0;
  char* cp;
  while((cp = (char*)stringStash.fetch(k++))!=0)
    cout << "stringStash.fetch(" << k << ") = "
         << cp << endl;
} ///:~

Also notice how the cleanup( ) calls have been eliminated, but the destructors are still automatically called when intStash and stringStash go out of scope.

One thing to be aware of in the Stash examples: I'm being very careful to use only built-in types; that is, those without destructors. If you were to try to copy class objects into the Stash, you'd run into all kinds of problems and it wouldn't work right. The Standard C++ Library can actually make correct copies of objects into its containers, but this is a rather messy and complicated process. In the following Stack example, you'll see that pointers are used to sidestep this issue, and in a later chapter the Stash will be converted so that it uses pointers.

6-5. Stack with constructors & destructors

Reimplementing the linked list (inside Stack)with constructors and destructors shows how neatly constructors and destructors work with new and delete. Here's the modified header file:

 
Sélectionnez
//: C06:Stack3.h
// With constructors/destructors
#ifndef STACK3_H
#define STACK3_H
 
class Stack {
  struct Link {
    void* data;
    Link* next;
    Link(void* dat, Link* nxt);
    ~Link();
  }* head;
public:
  Stack();
  ~Stack();
  void push(void* dat);
  void* peek();
  void* pop();
};
#endif // STACK3_H ///:~

Not only does Stack have a constructor and destructor, but so does the nested struct Link:

 
Sélectionnez
//: C06:Stack3.cpp {O}
// Constructors/destructors
#include "Stack3.h"
#include "../require.h"
using namespace std;
 
Stack::Link::Link(void* dat, Link* nxt) {
  data = dat;
  next = nxt;
}
 
Stack::Link::~Link() { }
 
Stack::Stack() { head = 0; }
 
void Stack::push(void* dat) {
  head = new Link(dat,head);
}
 
void* Stack::peek() { 
  require(head != 0, "Stack empty");
  return head->data; 
}
 
void* Stack::pop() {
  if(head == 0) return 0;
  void* result = head->data;
  Link* oldHead = head;
  head = head->next;
  delete oldHead;
  return result;
}
 
Stack::~Stack() {
  require(head == 0, "Stack not empty");
} ///:~

The Link::Link( ) constructor simply initializes the data and next pointers, so in Stack::push( ) the line

 
Sélectionnez
head = new Link(dat,head);

not only allocates a new link (using dynamic object creation with the keyword new, introduced in Chapter 4), but it also neatly initializes the pointers for that link.

You may wonder why the destructor for Link doesn't do anything - in particular, why doesn't it delete the data pointer? There are two problems. In Chapter 4, where the Stack was introduced, it was pointed out that you cannot properly delete a void pointer if it points to an object (an assertion that will be proven in Chapter 13). But in addition, if the Link destructor deleted the data pointer, pop( ) would end up returning a pointer to a deleted object, which would definitely be a bug. This is sometimes referred to as the issue of ownership: the Link and thus the Stack only holds the pointers, but is not responsible for cleaning them up. This means that you must be very careful that you know who is responsible. For example, if you don't pop( ) and delete all the pointers on the Stack, they won't get cleaned up automatically by the Stack's destructor. This can be a sticky issue and leads to memory leaks, so knowing who is responsible for cleaning up an object can make the difference between a successful program and a buggy one - that's why Stack::~Stack( ) prints an error message if the Stack object isn't empty upon destruction.

Because the allocation and cleanup of the Link objects are hidden within Stack - it's part of the underlying implementation - you don't see it happening in the test program, although you are responsible for deleting the pointers that come back from pop( ):

 
Sélectionnez
//: C06:Stack3Test.cpp
//{L} Stack3
//{T} Stack3Test.cpp
// Constructors/destructors
#include "Stack3.h"
#include "../require.h"
#include <fstream>
#include <iostream>
#include <string>
using namespace std;
 
int main(int argc, char* argv[]) {
  requireArgs(argc, 1); // File name is argument
  ifstream in(argv[1]);
  assure(in, argv[1]);
  Stack textlines;
  string line;
  // Read file and store lines in the stack:
  while(getline(in, line))
    textlines.push(new string(line));
  // Pop the lines from the stack and print them:
  string* s;
  while((s = (string*)textlines.pop()) != 0) {
    cout << *s << endl;
    delete s; 
  }
} ///:~

In this case, all the lines in textlines are popped and deleted, but if they weren't, you'd get a require( ) message that would mean there was a memory leak.

6-6. Aggregate initialization

An aggregate is just what it sounds like: a bunch of things clumped together. This definition includes aggregates of mixed types, like structs and classes. An array is an aggregate of a single type.

Initializing aggregates can be error-prone and tedious. C++ aggregate initialization makes it much safer. When you create an object that's an aggregate, all you must do is make an assignment, and the initialization will be taken care of by the compiler. This assignment comes in several flavors, depending on the type of aggregate you're dealing with, but in all cases the elements in the assignment must be surrounded by curly braces. For an array of built-in types this is quite simple:

 
Sélectionnez
int a[5] = { 1, 2, 3, 4, 5 };

If you try to give more initializers than there are array elements, the compiler gives an error message. But what happens if you give fewer initializers? For example:

 
Sélectionnez
int b[6] = {0};

Here, the compiler will use the first initializer for the first array element, and then use zero for all the elements without initializers. Notice this initialization behavior doesn't occur if you define an array without a list of initializers. So the expression above is a succinct way to initialize an array to zero, without using a for loop, and without any possibility of an off-by-one error (Depending on the compiler, it may also be more efficient than the for loop.)

A second shorthand for arrays is automatic counting, in which you let the compiler determine the size of the array based on the number of initializers:

 
Sélectionnez
int c[] = { 1, 2, 3, 4 };

Now if you decide to add another element to the array, you simply add another initializer. If you can set your code up so it needs to be changed in only one spot, you reduce the chance of errors during modification. But how do you determine the size of the array? The expression sizeof c / sizeof *c (size of the entire array divided by the size of the first element) does the trick in a way that doesn't need to be changed if the array size changes(42):

 
Sélectionnez
for(int i = 0; i < sizeof c / sizeof *c; i++)
 c[i]++;

Because structures are also aggregates, they can be initialized in a similar fashion. Because a C-style struct has all of its members public, they can be assigned directly:

 
Sélectionnez
struct X {
  int i;
  float f;
  char c;
};
 
X x1 = { 1, 2.2, 'c' };

If you have an array of such objects, you can initialize them by using a nested set of curly braces for each object:

 
Sélectionnez
X x2[3] = { {1, 1.1, 'a'}, {2, 2.2, 'b'} };

Here, the third object is initialized to zero.

If any of the data members are private (which is typically the case for a well-designed class in C++), or even if everything's public but there's a constructor, things are different. In the examples above, the initializers are assigned directly to the elements of the aggregate, but constructors are a way of forcing initialization to occur through a formal interface. Here, the constructors must be called to perform the initialization. So if you have a struct that looks like this,

 
Sélectionnez
struct Y {
  float f;
  int i;
  Y(int a);
};

You must indicate constructor calls. The best approach is the explicit one as follows:

 
Sélectionnez
Y y1[] = { Y(1), Y(2), Y(3) };

You get three objects and three constructor calls. Anytime you have a constructor, whether it's a struct with all members public or a class with private data members, all the initialization must go through the constructor, even if you're using aggregate initialization.

Here's a second example showing multiple constructor arguments:

 
Sélectionnez
//: C06:Multiarg.cpp
// Multiple constructor arguments
// with aggregate initialization
#include <iostream>
using namespace std;
 
class Z {
  int i, j;
public:
  Z(int ii, int jj);
  void print();
};
 
Z::Z(int ii, int jj) {
  i = ii;
  j = jj;
}
 
void Z::print() {
  cout << "i = " << i << ", j = " << j << endl;
}
 
int main() {
  Z zz[] = { Z(1,2), Z(3,4), Z(5,6), Z(7,8) };
  for(int i = 0; i < sizeof zz / sizeof *zz; i++)
    zz[i].print();
} ///:~

Notice that it looks like an explicit constructor is called for each object in the array.

6-7. Default constructors

A default constructor is one that can be called with no arguments. A default constructor is used to create a “vanilla object,” but it's also important when the compiler is told to create an object but isn't given any details. For example, if you take the struct Y defined previously and use it in a definition like this,

 
Sélectionnez
Y y2[2] = { Y(1) };

the compiler will complain that it cannot find a default constructor. The second object in the array wants to be created with no arguments, and that's where the compiler looks for a default constructor. In fact, if you simply define an array of Y objects,

 
Sélectionnez
Y y3[7];

the compiler will complain because it must have a default constructor to initialize every object in the array.

The same problem occurs if you create an individual object like this:

 
Sélectionnez
Y y4;

Remember, if you have a constructor, the compiler ensures that construction always happens, regardless of the situation.

The default constructor is so important that if (and only if) there are no constructors for a structure (struct or class), the compiler will automatically create one for you. So this works:

 
Sélectionnez
//: C06:AutoDefaultConstructor.cpp
// Automatically-generated default constructor
 
class V {
  int i;  // private
}; // No constructor
 
int main() {
  V v, v2[10];
} ///:~

If any constructors are defined, however, and there's no default constructor, the instances of V above will generate compile-time errors.

You might think that the compiler-synthesized constructor should do some intelligent initialization, like setting all the memory for the object to zero. But it doesn't - that would add extra overhead but be out of the programmer's control. If you want the memory to be initialized to zero, you must do it yourself by writing the default constructor explicitly.

Although the compiler will create a default constructor for you, the behavior of the compiler-synthesized constructor is rarely what you want. You should treat this feature as a safety net, but use it sparingly. In general, you should define your constructors explicitly and not allow the compiler to do it for you.

6-8. Summary

The seemingly elaborate mechanisms provided by C++ should give you a strong hint about the critical importance placed on initialization and cleanup in the language. As Stroustrup was designing C++, one of the first observations he made about productivity in C was that a significant portion of programming problems are caused by improper initialization of variables. These kinds of bugs are hard to find, and similar issues apply to improper cleanup. Because constructors and destructors allow you to guarantee proper initialization and cleanup (the compiler will not allow an object to be created and destroyed without the proper constructor and destructor calls), you get complete control and safety.

Aggregate initialization is included in a similar vein - it prevents you from making typical initialization mistakes with aggregates of built-in types and makes your code more succinct.

Safety during coding is a big issue in C++. Initialization and cleanup are an important part of this, but you'll also see other safety issues as the book progresses.

6-9. Exercises

Solutions to selected exercises can be found in the electronic document The Thinking in C++ Annotated Solution Guide, available for a small fee from www.BruceEckel.com.

  1. Write a simple class called Simple with a constructor that prints something to tell you that it's been called. In main( ) make an object of your class.
  2. Add a destructor to Exercise 1 that prints out a message to tell you that it's been called.
  3. Modify Exercise 2 so that the class contains an int member. Modify the constructor so that it takes an int argument that it stores in the class member. Both the constructor and destructor should print out the int value as part of their message, so you can see the objects as they are created and destroyed.
  4. Demonstrate that destructors are still called even when goto is used to jump out of a loop.
  5. Write two for loops that print out values from zero to 10. In the first, define the loop counter before the for loop, and in the second, define the loop counter in the control expression of the for loop. For the second part of this exercise, modify the identifier in the second for loop so that it as the same name as the loop counter for the first and see what your compiler does.
  6. Modify the Handle.h, Handle.cpp, and UseHandle.cpp files at the end of Chapter 5 to use constructors and destructors.
  7. Use aggregate initialization to create an array of double in which you specify the size of the array but do not provide enough elements. Print out this array using sizeof to determine the size of the array. Now create an array of double using aggregate initialization and automatic counting. Print out the array.
  8. Use aggregate initialization to create an array of string objects. Create a Stack to hold these strings and step through your array, pushing each string on your Stack. Finally, pop the strings off your Stack and print each one.
  9. Demonstrate automatic counting and aggregate initialization with an array of objects of the class you created in Exercise 3. Add a member function to that class that prints a message. Calculate the size of the array and move through it, calling your new member function.
  10. Create a class without any constructors, and show that you can create objects with the default constructor. Now create a nondefault constructor (one with an argument) for the class, and try compiling again. Explain what happened.

précédentsommairesuivant
C99, The updated version of Standard C, allows variables to be defined at any point in a scope, like C++.
An earlier iteration of the C++ draft standard said the variable lifetime extended to the end of the scope that enclosed the for loop. Some compilers still implement that, but it is not correct so your code will only be portable if you limit the scope to the for loop.
The Java language considers this such a bad idea that it flags such code as an error.
OK, you probably could by fooling around with pointers, but you'd be very, very bad.
In Volume 2 of this book (freely available at www.BruceEckel.com), you'll see a more succinct calculation of an array size using templates.

Ce document est issu de http://www.developpez.com et reste la propriété exclusive de son auteur. La copie, modification et/ou distribution par quelque moyen que ce soit est soumise à l'obtention préalable de l'autorisation de l'auteur.