MindView Inc.
[ Viewing Hints ] [ Exercise Solutions ] [ Volume 2 ] [ Free Newsletter ]
[ Seminars ] [ Seminars on CD ROM ] [ Consulting ]

Thinking in C++, второе издание

©2000 by Bruce Eckel

[ Предыдущая глава ] [ Содержание ] [ Указатель ] [ Следующая глава ]

 

6: Инициализация и очистка

В главе 4 показано построение библиотеки путем собирания вместе всех разрозненных компонентов из типичной C библиотеки и инкапсуляции их в единую структуру (абстрактный тип данных, называемый классом).

Такой подход позволяет связать имена функций с именами классов. В главе 5 был представлен механизм скрытия реализации. С его помощью проектировщики класса могут определить рамки, в пределах которых программисты могут использовать этот класс. Это означает, что внутренний мир класса полностью под контролем у его проектировщика. И для программиста видимы только те методы, на которые он может и должен обращать внимание.

Совместно, инкапсуляция и контроль доступа позволяют сделать значительный шаг в увеличении простоты  использования библиотеки. Концепция  “нового типа данных”, которую на них отроется, обладает рядом преимуществ перед строенными типа данных в языка C. Компилятор C++ имеет по возможности для проверки этих новых типов и тем самым гарантирует определенный уровень безопасности кода.

С точке зрения предоставления безопасности кода язык C++ шагнул далеко по сравнению с языком С. В этой и следующих главах вы увидите дополнительные возможности, появившиеся в C++, которые позволяют значительно уменьшить количество ошибок в программах на этапе их написания и компиляции. И скоро вы начнете использовать невероятно звучащий лозунг, что программы C++, которые компиляются без ошибок часто правильно работают уже при первом запуске.

Два очень важных момента для обеспечения безопасности кода - это инициализация и очистка. Целый ряд ошибок C был связан с тем, что программисты забывали проинициализировать или очистить переменную. Это особенно заметно в библиотеках C, когда программисты просто не знали как правильно инициализировать структуры. (Библиотеки часто не содержали функций для инициализации, и программисты были вынуждены инициализировать эти структуры руками). Очистка - это еще одна большая проблема, так как программисты C очень любили забывать о своих переменных, как только они переставали быть им нужны, и таким образом про необходимую для библиотеки очистку просто забывали.

В C++ концепция инициализации и очистки привела к существенно более простому использованию библиотек и позволила избежать целого ряда ошибок, характерных для C. Эта глава раскрывает эту возможность C++, помогающую гарантировать правильную инициализацию и очистку.

Инициализация и конструктор

В обоих классах Stash и Stack, определенных ранее, существует функция initialize( ), которая должна быть вызвана до первого использования объекта. К сожалению, это означает, что на программистов ложится обязанность проводить правильную инициализацию объектов. Но в безудержной спешке воспользоваться вашей замечательной библиотекой, программисты часто забывают о таких мелочах как инициализация. В C++ инициализация настолько важная вещь, что оставлять ее на совесть программистов не очень хорошая идея. Разработчики классов могут гарантировать инициализацию каждого объекта путем определения особой функции, которая называется конструктор (constructor). Если в классе определен конструктор, компилятор автоматически вызывает его в момент создания объекта, до того как отдать его в руки программисту. Программисты не праве даже решать, вызывать конструктор или нет, конструктор вызывается всегда компилятором в момент создания объекта.

Другой вопрос - какое имя дать этой функции. Здесь два момента. Во-первых, это имя вы не можете  использовать для какой-то еще функции класса (не конструктора). Во-вторых, так как конструктор вызывается компилятором, то он всегда должен знать какая функция является конструктором.  Решение, предложенное Страуструпом, кажется наиболее простым и логичным:  имя конструктора  совпадает с именем класса. Тем самым дается понять, что эта функция будет вызвана автоматически для инициализации объекта.

Вот пример простого класса с конструктором:

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

А здесь создание объекта:

void f() {
  X a;
  // ...
} 

Здесь происходит то же самое, что происходило бы будь a типа int: выделение памяти для хранения объекта. Единственное отличие в том, что когда программа дойдет до точки, где создается объект a, будет автоматически вызван конструктор. То есть, компилятор просто вставляет вызов функции X::X( ) для объекта a в момент его создания. Как и у любого другого метода, у конструктора есть первый (секретный) аргумент:  this – адрес на объект, для которого этот метод вызывается.  В случае конструктора, однако, this  указывает на неинициализированный блок памяти, и как раз задача конструктора - правильно проинициализировать этот блок памяти.

Как и у любого метода, у конструктора могут быть аргументы, чтобы можно было иметь возможность указать как создавать объект, передать какие-то значения и т.д. Аргументы конструктора дают возможность гарантировать, что все части объекта могут быть проинициализированы подходящими значениями. Например, если у класса Tree есть конструктор, принимающий в качестве параметра ширину дерева, тогда вы должны создавать объект этого класса примерно таким образом:

Tree t(12);  // дерево ширины 12

Если Tree(int) единственный конструктор, то тогда компилятор не позволит вам создать объект каким-либо другим образом. (Мы рассмотрим множественные конструкторы и различные способы создания объектов в следующей главе.)

И так, конструктор - это специальный метод, который вызывается автоматически в момент создания каждого объекта. Несмотря на свою простоту, конструкторы имеет очень важное значение, они помогают избежать целого класса проблем и делают код проще для написания и чтения. В предыдущем фрагменте, к примеру, вы не видите явного вызова метода initialize( ) или другого подобного. В C++, определение объекта и его инициализация неразрывные понятия, вы не можете иметь одно без другого.

И конструктор, и деструктор - очень необычные методы: они не возвращают никаких значений. Это отличается от методов, которые возвращают значение void. Для обычных методов вы можете указать какое угодно возвращаемое значение, для конструкторов и деструкторов вы не имеете такой возможности. Процессы создания и уничтожения объектов особые, как рождение и смерть, поэтому компилятор производит вызов конструкторов и деструкторов сам, чтобы быть уверенным правильно ли это делается.  И если бы эти операции возвращали значения, или вы бы могли указать свои собственные, то тогда бы пришлось компилятору каким-либо образом дать знать, что с этими значениями делать, или возложить на программистов обязанность вызывать конструкторы и деструкторы самим, тем самым они бы потеряли такое свое замечательное свойство как безопасность использования инициализации и очистки.

Очистка и деструктор

Как C-программист, вы часто думаете о важности инициализации, и гораздо реже о важности очистки. И ко всему прочему, что нужно очищать после int? Только забыть об этом. Однако, при использовании библиотек подход оставить объект "так как есть" после того как стал не нужен, потенциально опасен.  А что если он изменяет параметры каких-нибудь устройств, или выводит что-нибудь на экран, или выделяет память? Если вы забудете сами сделать очистку, то объект не сделает ее за вас. В C++, очистка поставлена на одну планку по важности с инициализацией и гарантируется, что она будет осуществлена путем вызова деструктора.

Синтаксис деструктора и конструктора схожи. И там, и там, в качестве имени метода используется имя класса. Однако, чтобы отличить деструктор от конструктора, перед именем деструктора добавляется символ тильда (~). Кроме того, деструктор не может иметь какие-либо аргументы, так как для процесса уничтожения не требуется дополнительных параметров. Вот пример объявления деструктора:

class Y {
public:
  ~Y();
}; 

Деструктор вызывается автоматически компилятором, когда объект выходит из своей области существования. Другими словами, конструктор будет вызван на том месте, где находится определение объекта, а деструктор - там, где находится закрывающая скобка того блока, в котором этот объект был определен. Деструктор будет вызван, даже если вы воспользуетесь  goto , чтобы выйти из блока. (goto по-прежнему существует в C++ для совместимости с C и тех случаев, где его применение оправдано.) В отличии от goto, нелокальные переходы (реализованные в стандартной библиотеке при помощи функций setjmp( ) и longjmp( )) не приводят к вызову деструкторов. (Так указано в спецификации, и даже если ваш компилятор может это делать, полагаться на это нельзя, иначе ваш код станет не переносимым.)

Вот пример использования конструктора и деструктора:

//: 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;
} ///:~

Вот вывод этой программы:

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

Как вы можете заметить, деструктор вызывается автоматически в том месте, где находится закрывающая скобка блока, в котором определен объект.

Устранение блока определений

В C, вы должны всегда определять все переменные в начале блока, сразу после открывающей фигурной скобки. Это требование характерно и для ряда других языком программирования. Причина проста - это "хороший стиль программирования".  На это у меня есть свое собственное мнение, мне ,как программисту, кажется неудобным перескакивать в начало блока каждый раз, когда мне нужна новая переменная. Я также нахожу код более читабельным, если определение переменных находится близко от того места, где они используются.

Возможно, эти аргументы покажутся стилистическими. В C++, однако, порой довольно сложно определить все объекты в начале блока. Если конструктор для класса определен, то он должен быть вызван в месте определения объекта. Но если конструктор принимает аргументы, то сможете ли вы знать эти значения уже в начале блока? Как правило, нет. Это связанно с тем, что в C++ определение объекта подразумевает и его инициализацию, а в C нет. Более того, C подталкивает подобной практикой к разделению определений переменных и их инициализаций[38].

C++ не позволит вам создать объект до того, как вы узнаете все значения, необходимые для инициализации этого объекта. Поэтому C++ не полагается на то, что вы будете создавать все переменные в начале блока. Фактически, стиль языка подталкивает помещать определения объектов как можно ближе к месту их использования. В C++, любые правила применимые к "объекту" так же применимы и к любому встроенному типу. Это означает, что любой объект класса и переменная встроенного типа могут быть определены в любом месте блока. Это также значит, что вы можете подождать с определением переменной пока вы не получите всю необходимую информацию для е╦ определения, тем самым вы всегда можете определить и проинициализировать переменную в одном месте:

//: 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);
} ///:~

Вы можете видеть, что retval, y и g определяются лишь тогда, когда известны их инициализирующие значения.

И так итог, вы должны определять переменные как можно ближе к месту их использования и всегда их сразу инициализировать. (Это стилистическая рекомендация для встроенных типов данных, где инициализация может быть пропущена). Это позволяет сделать код более безопасным, так как уменьшение времени существования неинициализированной переменной ведет к уменьшению вероятности е╦ неправильного использования в другой области блока. Ко всему прочему, читабельность становится лучше, так как не надо прыгать туда и сюда, чтобы узнать тип переменной.

циклы

В C++ вы часто можете видеть, что переменная-счетчик для цикла for определяется непосредственно внутри выражения for:

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

Подобное конструкции часто ставят новичков C++ в тупик.

Переменные i и j определены непосредственно внутри выражения for (что невозможно в C). Они доступны для использования внутри цикла for. Это очень удобно, так как сразу становится понятным назначение этих переменных, и вам не нужно использовать  такие неуклюжие имена, как скажем i_loop_counter для прояснения назначения переменной.

Здесь есть еще одна особенность. Область жизни переменных i и j простирается только на тело цикла, в котором они определены.[39].

В главе 3 упоминается, что внутри конструкций while и switch также позволительно определять объекты, хотя они обычно играют там гораздо меньшую роль, чем в цикле for.

Будьте внимательны, локальные переменные  скрывают одноименные переменные с более высокой областью видимости. Как правило, использование одинаковых имен для внутренних и глобальных переменных ведет к образованию конфликтов и делает код более подверженным к ошибкам[40].

Я нахожу, что маленькие блоки - это индикатор хорошего дизайна. Если код вашей функции занимает несколько страниц, то, возможно, вы пытаетесь сделать слишком много для одной функции. Более компактные функции не только более полезны, но и в них легче искать ошибки.

Выделение памяти

Так как переменные могут быть определенны в любом месте блока, то может показаться, что место для хранения переменной не выделяется пока выполнение программы не дойдет до е╦ места определения в программе. На самом деле, как правило, это не так. Память для хранения переменных выделяется как и в C, в начале блока. Но пусть это вас не смущает, потому что вы все равно не сможете получить доступ к этой памяти (читай к объекту), пока переменная не будет определена[41]. Хотя память для объекта выделяется в начале блока, конструктор не вызывается до тех пор, пока выполнение программы не дойдет до места определения этого объекта. Более того, компилятор проверяет не поместили ли вы определение объекта (а значит и вызов конструктора) внутрь условной конструкции, такой как switch  или goto . Так, если вы разкоментируете строки в следующем примере, то в результате компиляции получите сообщения о предупреждениях или ошибках:

//: 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);
}///:~ 

В этом коде и goto и switch могут потенциально привести, что выполнение программы может не дойти до места определения того или иного объекта, и тем самым не произойдет вызов конструктора. И тогда этот объект будет находиться в области видимости, хотя он не был правильно проинициализирован. Компилятор выдаст сообщение об ошибки на подобную ситуацию. Тем самым гарантируется, что объект не может быть создан без соответствующей инициализации.

Выделение памяти для всех объектов, рассмотренных выше, происходит, конечно же в стеке.. Выделение производится компилятором, путем сдвига указателя стека "вниз" (это относительный термин, который может указывать на увеличения или уменьшения значения указателя стека, что зависит от типа вашей машины). Объекты также могут быть расположены в динамической памяти (в кучи). Для этого их необходимо создать при помощи оператора new, но об этом мы поговорим подробнее в главе 13.

Stash с конструктором и деструктором

Примеры из предыдущих глав содержат методы для инициализации и очистки: initialize( ) и cleanup( ). Теперь заботу об этом берут на себя конструктор и дестрктор. Вот пример заголовочного файла и файла с исходным кодом для класса Stash с использованием конструктора и деструктора:

//: 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 ///:~

Единственное отличие в том, что исчезли методы initialize( ) и cleanup( ), их заменили конструктор и деструктор:

//: 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;
  }
} ///:~

Вы можете заметить, что для контроля ошибок используются функция require() (объявленная в файле require.h), вместо стандартной функции assert( ). Это связанно с тем, что вывод assert( ) не столь полезен, чем вывод функции require() (которая будет рассмотрена дальше в этой книге).

Так как inflate( ) - закрытый метод, то единственная возможность когда require( ) может выдать сообщение об ошибке, это когда другие методы случайно передадут неверные значения методу inflate( ). Если вы считаете, что этого не может случиться, то можете убрать вызов require( ), но всегда держите в голове, что хотя код стабилен, он может стать не стабильным если вы добавите новые методы, которые будут вызывать inflate(). Стоимость вызова require( ) низка (более того, е╦ вызов может быть удален предпроцессором), а значение устойчивости кода очень важно.

Обратите внимание, что в следующей тестовой программе, определение объектов класса Stash появляются только там, где эти объекты необходимы, и как связаны инициализация и определение при помощи списка параметров для конструктора:

//: 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;
} ///:~

Также обратите внимание на то, что хотя вызовы cleanup( ) отсутствуют, но  деструкторы вызываются автоматически, когда intStash и stringStash выходят из блока.

Одно замечание по поводу примера Stash: Я был очень внимателен, чтобы использовать только встроенные типы; то есть без деструкторов. Если вы постараетесь скопировать объекты классов в  Stash, вы получите целый букет проблем, которые могут привести к тому, что ваша программа будет работать неправильно. Стандартная библиотека C++ умеет корректно помещать копии объектов в контейнеры, но это довольно  сложный процесс.

Пример с переделанным связанным списоком (внутри Stack) демонстрирует с какой ловкостью конструкторы и деструкторы работают с операторами new и delete. Вот модифицированный заголовочный файл:

//: 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 ///:~

Не только у класса Stack появились конструктор и деструктор, но и у внутреннего класса Link:

//: 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");
} ///:~

Конструктор Link::Link( ) просто инициализирует указатели data и next, так в Stack::push( ) строка

head = new Link(dat,head);

не только выделяет память для нового объекта (используя динамическое выделение памяти при помощи ключевого слова new, с которым мы познакомились в главе 4), но и инициализирует поля этого объекта (путем вызова конструктора).

Вы можете задать вопрос, почему деструктор для класса Link ничего не делает – в частности, почему не удаляет при помощи delete указатель data? Это есть две причины. В главе 4, где был представлен класс Stack, указано, что вы не можете корректно удалить указатель на void, если реально это указатель на объект (это утверждение будет объяснено в главе 13). Кроме того, если деструктор класса Link удалит указатель data, метод pop( ) будет возвращать указатель на удаленный объект, что является определенно ошибкой. В подобных случаях часто прибегают к понятию владения данными (ownership): объекты класса Link и поэтому класса Stack только хранят указатели на данные, но не отвечают за их очистку. Это означает, что вы должны действовать очень осторожно, и четко представлять, кто ответственен за уничтожение объектов. Например, если вы хотите удалять сами указатели, возвращаемые методом pop( ),  то не забывайте этого делать, так как автоматически при вызове деструктора они удаляться не будут. А это ведет к утечкам памяти, таким образом, четкое представление, кто отвечает за удаление  объектов, помогает избежать ряда ошибок в программе – вроде той, которую выдает Stack::~Stack( ) если к моменту уничтожения объекта Stack он не пуст.

Так как выделение и освобождения объектов класса Link скрыто внутри класса Stack – это часть реализации – вы не будете видеть как как и где это происходит в тестовой программе, хотя вы по-прежнему будете ответственны за удаление указателей которые вы получаете при вызове метода pop( ):

//: 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; 
  }
} ///:~

В этом примере все объекты востребованы и удалены, но если вы забудете это сделать, то получите сообщение от require( ), что будет означать утечку памяти.

Инициализация агрегаций(включений)

Агрегация или включение - это объединение отдельных данных в единую сущность. Так, в нашем случае, агрегация данных разных типов - это объект класса или структуры, а массив - это агрегация значений одинаковых типов.

Инициализация агрегаций весьма скучный процесс, легко подверженный ошибкам. В C++ этот процесс выглядит более безопасным. Когда вы создаете объект (частный случай агрегации), все что вы должны сделать - это выполнить присвоение, и заботу об инициализации возьмет на себя компилятор. Это присвоение может нескольких варьироваться , в зависимости от типа агрегации с которой вы имеете дело, но в любом случае все элементы присвоения должны быть окружены фигурными скобками. Для массива встроенных типов это выглядит следующем образом:

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

Если вы попытаетесь указать больше инициализирующих значений, чем количество элементов в массиве, компилятор выдаст сообщение об ошибке. Но что случится, если вы укажете меньше значений? Например:

int b[6] = {0};

Здесь компилятор будет использовать указанное значение для первого элемента массива, и использовать ноль для всех остальных. Заметьте однако, что подобного не происходит, если вы определяете массив без списка инициализаторов. Таким образом, приведенный выше пример - это лаконичный способ определить массив нулей, без использования цикла for, и без всяких ошибок, типа не угадал на единицу количество элементов (Также, в зависимости от компилятора , это может быть более эффективный способ инициализации, чем цикл for.)

Второй способ инициализации массива - автоматический подсчет, где вы возлагаете на компилятор обязанность определить количество элементов в массиве, путем подсчета количества инициализаторов:

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

Если теперь вы захотите добавить еще один элемент в массив, просто добавьте еще один инициализатор. Таким образом, вы смогли так построить код, что для изменения количества элементов в массиве вам нужно произвести изменение только в одном месте, тем самым вы уменьшаете возможность допустить ошибку при модификации кода.  Но как теперь узнать размер массива? Выражение sizeof c / sizeof *c (размер всего массива дел╦нный на размер первого элемента) позволит вам всегда точно узнать количество элементов в массиве[42]:

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

Т.к. объекты структур также являются агрегациями, они могут быть проинициализированы сходным образом. Так как все поля структуры (структуры стиля C) имеют область видимости public, им могут быть присвоены значения напрямую:

struct X {
  int i;
  float f;
  char c;
};

X x1 = { 1, 2.2, 'c' };

Если у вас есть массив подобных объектов, вы можете проинициализировать его с использованием вложенных блоков фигурных скобок, по одному для каждого объекта:

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

Здесь третий объект проинициализирован нулем.

Если есть хотя бы одно поле в структуре с областью видимости отличной от public (структура стиля C++), или присутствует конструктор (даже если все поля public), инициализация происходит по другому. В приведенных выше примерах, инициализаторы присваиваются непосредственно элементам агрегации, здесь же используется конструктор, то есть инициализация происходит через формальный интерфейс. Так, если ваша структура имеет следующий вид:

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

Вы должны указать для инициализации вызов конструктора. Наиболее лучший способ, это явный вызов:

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

Вы получили три объекта, а значит конструктор вызвался три раза. Если у вышей структуры есть конструктор, то вне зависимости какую область видимости имеют поля этой структуры, вся инициализация должна идти через конструктор, даже если вы используете инициализацию агрегации.

Вот второй пример с использованием конструктора с несколькими аргументами:

//: 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();
} ///:~

Обратите внимание, что это выглядит так, что для каждого объекта в массиве явно вызывается конструктор.

Конструкторы по умолчанию

Конструктор по умолчанию(default constructor) - это конструктор, который может быть вызван без аргументов. Этот конструктор очень важен, он используется когда от компилятора требуется создать объект, без  знания каких-либо деталей о его инициализации. Например, если вы воспользуетесь структурой Y из предыдущего примера, то на следующее определение

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

компилятор пожалуется, что не может найти конструктор по умолчанию. Для второго объекта не указана никакая информация об инициализации, компилятор попытается вызвать конструктор без параметров - конструктор по умолчанию. Но такого конструктора нет. Подобная ситуация возникнет, если вы попытаетесь определить массив следующем образом:

Y y3[7];

Компилятор будет жаловаться, так как ему необходим конструктор по умолчанию для инициализации каждого элемента массива.

Какая же проблема возникнет если вы попытаетесь создать отдельный объект:

Y y4;

Запомните, если вы имеете конструктор, то компилятор гарантирует, что конструктор будет вызван всегда в любой ситуации.

Конструктор по умолчанию настолько важен, что если (и только если) у структуры или класса нет никакого конструктора, то компилятор создаст конструктор по умолчанию автоматически. Так, это сработает:

//: C06:AutoDefaultConstructor.cpp
// Automatically-generated default constructor

class V {
  int i;  // private
}; // No constructor

int main() {
  V v, v2[10];
} ///:~

Если бы в классе V был явно определен хотя бы один конструктор, но не конструктор по умолчанию, компиляция этого примера прошла бы с ошибкой.

Вы можете предположить, что подобный синтезированный компилятором конструктор должен делать какую-то интеллектуальную инициализацию, такую как обнуление все памяти, отводимой для объекта. Но это не так – возможно это добавит работу программисту, но все по прежнему остается у него в руках. Если вы хотите проинициализировать память нулями, вы должны описать конструктор по умолчанию явно, и в н╦м поместить код, обнуляющий память.

Хотя компилятор может создавать конструктор по умолчанию за вас, но такой синтетический конструктор редко делает то, что нужно вам. Вы должны думать об этой возможности компилятора, просто как об удобстве, но не пользоваться ей часто. Вообще-то было бы даже лучше определять конструктор по умолчанию самим, и не позволять создавать его за вас компилятору.

Резюме

Тщательно разработанный механизм, предоставляемый языком программирования C++, показывает, насколько важным считаются в языке такие процессы как инициализация и очистка. Когда Страуструп разрабатывал C++, одно из первых наблюдений, которое он сделал, заключалось в том, что в программах на C наблюдается большое количество проблем, связанных с неправильной инициализацией переменных. Подобные ошибки трудно найти, что справедливо и для ошибок, связанных с неправильной очисткой. Так как конструкторы и деструкторы гарантируют правильную инициализацию и очистку (компилятор не позволяет создание и уничтожение объектов без вызова соответственно конструктора и деструктора), ваш код становится более безопасным.

Инициализация агрегации позволяет вам избежать ошибок при инициализации агрегаций встроенных типов и делает ваш код более лаконичным.

Безопасность кода, гарантируемая синтаксисом - большое достижение в C++. Проявление этого вы наблюдается и в инициализации, и в очистки, и многом другом, что вы встретите  по мере прочтения книги.

Задания

Решение некоторых заданий может быть найдено в электронном документе The Thinking in C++ Annotated Solution Guide, доступном за небольшую плату на сайте www.BruceEckel.com.

  1. Напишите простой класс Simple с конструктором, который печатает на экране текст, что бы можно было определить когда он вызывается. В main( ) создайте объект этого класса.
  2. Добавьте деструктор к классу из задания 1, который печатает на экране текст, что бы можно было определить когда он вызывается.
  3. Модифицируйте класс из задания 2 так, чтобы он содержал поле типа int. Измените конструктор так, чтобы он принимал в качестве параметра значение типа int и присваевал его этому полю. И конструктор, и деструктор должны печатать на экране значение этого поля.
  4. Продемонстрируйте, что деструктор по-прежнему вызывается, если использовать goto для выхода из блока.
  5. Напишите два for цикла, которые печатают значения от нуля до 10. В первом цикле определите итерационную переменную вне цикла, а во втором - внутри выражения for. Во второй части этого задания измените имя итерационной переменной для второго цикла так, чтобы оно совпало с именем переменной для первого цикла. Объясните поведение компилятора.
  6. Измените файлы Handle.h, Handle.cpp и UseHandle.cpp из главы 5 так, чтобы были использованы конструкторы и деструкторы.
  7. Используйте инициализацию агрегации для инициализации массива из значений типа double. Укажите размер массива, но не указывайте всех инициализирующих значений. Распечатайте этот массив, использую sizeof для определения количества элементов в массиве. Теперь создайте массив значений типа double, используя инициализацию агрегации и автоматический подсчет. Распечатайте массив.
  8. Используйте инициализацию агрегации для создания массива строк (используйте класс string). Создайте класс Stack, способный хранить эти строки. Поместите каждую строку из вашего массива в объект класса Stack. В конце, получите эти строки из объекта Stack (при помощи метода pop) и распечатайте из на экране.
  9. Продемонстрируйте механизм автоматического подсчета и инициализацию агрегации на примере массива объектов класса из задания 3. Добавьте в этот класс метод, который печатает сообщение. Вычислите размер массива и пробежитесь по всем его элементам, вызывая у всех объектов этот метод.
  10. Создайте класс без конструкторов и покажите как вы можете создавать объекты этого класса при помощи конструктора по умолчанию. Теперь создайте для этого класса какой-нибудь конструктор с аргументами и попытайтесь снова откомпилировать программу. Объясните произошедшее.



[38] C99, Обновленная версия стандарта языка C, позволяющая создавать переменные в любом месте блока, как в C++.

[39] Ранняя черновая версия стандарта C++ расширяла область жизни переменной до конца блока, содержащего цикл  for. Некоторые компиляторы по-прежнему работают так, но это не правильно. Таким образом, ваша программа будет работать на других компиляторах только, если вы не будете использовать подобные переменные .

[40] Язык программирования  Java считает подобную ситуацию ошибкой.

[41] Вы возможно сможете обмануть компилятор при помощи  указателей, но это очень, очень плохая идея.

[42] В томе 2 этой книги (свободно доступной на сайте www.BruceEckel.com), вы сможете увидеть более элегантное вычисление размера массива, с помощью использование шаблонов.

[ Предыдущая глава ] [ Содержание ] [ Указатель ] [ Следующая глава ]