Си++: различия между версиями

Материал из Викиучебника — открытых книг для открытого мира
Содержимое удалено Содержимое добавлено
Строка 1059: Строка 1059:
</source>
</source>


TODO: какие именно типы можно использовать в качестве параметров?
TODO: какие именно типы можно использовать в качестве параметров? Интегральные.


TODO: найти ещё примеры применения этого вида параметров.
TODO: найти ещё примеры применения этого вида параметров. static assert.


==== Значения по-умолчанию у параметров шаблона ====
==== Значения по-умолчанию у параметров шаблона ====

Версия от 18:30, 5 января 2009

Это — вводный курс по объектно-ориентированному программированию на языке Си++.

Материал изложения примерно соответствует части курса ООП ФИТ НГУ (Новосибирский государственный университет) за третий семестр, касающейся Си++. Смотри список тем к экзамену.

Основные отличия Си++ от Си

Использование ссылок; передача аргументов по ссылке

Передача параметров в Си

В Си аргументы всегда передаются единственным образом, часто называемым «передачей по значению». Например, пусть есть функция

void foo (int x) 
{
  x = 17;
}

Мы можем вызывать её любым из следующих способов:

int main () 
{
  int z = 5;
  foo (z);
  foo (z + 1);
  foo (125);
}

Разумеется, ни один из этих вызовов не изменяет значения переменной z. (Для последних двух вызовов это совершенно естественно, а вот в первом случае вы могли засомневаться.)

Что же делать, если мы хотим дать возможность функции foo изменять значение переданной переменной? Мы можем передать ей не саму переменную, а указатель(и для C, и для C++). Перепишем предыдущий пример так:

void foo (int *x) 
{
  *x = 17;
}
int main () 
{
  int z = 5;
  foo (&z);
  /* остальные варианты больше не имеют смысла */
  /* z теперь равно 17 */
}

Что такое ссылка

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

  1. При объявлении ссылка обязательно инициализируется ссылкой на уже существующий объект данного типа.
  2. Ссылка пожизненно указывает на один и тот же адрес.
  3. При обращении к ссылке операция * производится автоматически.

Объявление ссылок очень похоже на объявление указателей, только вместо звёздочки «*» нужно писать амперсанд «&».

Пример:

int x = 10; 
int& y = x; // Теперь y - ссылка на int, указывающая на x.
y = 20; // теперь x=20
printf("x=%d y=%d\n", x, y); // напечатается "x=20 y=20"
/* int& z; */  // Запрещено!  Ссылки надо инициализировать!

Ссылки в основном используют для передачи параметров функциям:

void foo (int &x) {
  x = 17;
}
int main () {
  int z = 5;
  foo (z);
  /* z теперь равно 17 */
}

Что изменилось? Аргумент функции foo стал не указателем, а ссылкой. Поэтому теперь:

  • при вызове функции foo компилятор сам передаст адрес переменной z, нет необходимости специально его просить;
  • внутри функции foo мы обращаемся с x, как с обычной переменной, и только компилятор знает, что внутри это — указатель.

Некоторые программисты считают, что так делать не безопасно, ввиду того, что, вызывая функцию в формате foo(z), программист подразумевает, что данные копируются в локальный стек функции и не изменяются. Действия по взятию адреса и изменению значения могут быть совершенно неожиданными для программиста использующего вашу функцию.

С другой точки зрения, прототип функции явно указывает, что функция может изменить переменную, также как и документация(при условии её правильного составления).

Что дают ссылки

Есть в программировании такое понятие, как совмещение имён (по-английски — aliasing). Вернёмся к трём нашим примерам (приведу по первой строчке из каждого):

void foo (int x)
Здесь имена x и z обозначают совершенно разные вещи. (Если хотите, это названия разных ячеек памяти.)
void foo (int *x)
Здесь *x и z — два названия одного и того же. Есть в памяти место, которое внутри функции main называется z, а внутри foo*x.
void foo (int &x)
Здесь уже просто x и z — два названия одной и той же области памяти. Хотя по внешнему виду x невозможно отличить от обычной переменной, для компилятора это — просто указатель с другим лицом.

Чем отличаются ссылки от переменных

Пусть мы написали:

int z;
int &x = z;

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

Во-первых, мы можем написать «int z = 7;», а можем:

int z;
z = 7;

Это будет практически одно и то же.

Ссылки ведут себя совсем иначе. Запись «int &x = z;» означает, что «x отныне является ссылкой на область памяти, которая раньше называлась z». Если после этого написать «x = z;» это означало бы, что области памяти, на которую ссылается x, нужно присвоить значение переменной z. В нашем случае (когда x ссылается на z) это эквивалентно записи «z = z;».

Заметим, что когда мы пишем «int z;», то выделяется память для хранения величины типа int. А когда мы пишем «int &x = z;», мы пользуемся «чужой» памятью, то есть памятью, выделенной когда-то кем-то. Что очень важно: в этом случае не вызывается конструктор для переменной x (В данный момент думаю, не надо говорить про конструктор, потому что еще не известно, что это такое).

Когда время жизни переменной z заканчивается (в нашем примере — когда мы возвращаемся из main()), выделенная память освобождается. Если на неё остались какие-то указатели и ссылки — это ваши проблемы, и ваша программа непременно вскоре упадёт. У нас такого не происходит, потому что пока жива ссылка x, жива и переменная z.

Отличие ссылок от указателей

По сути ссылка — это указатель, который

  • обязательно при создании инициализировать каким-то значением;
  • нельзя изменять после этого.

Например, вот так написать вообще нельзя:

 int &x;

Ссылку обязательно инициализировать! Подойдёт любой из следующих способов:

int z;
int *pz = &z;
int &x1 = z;
int &x2 = *pz;
int &x3 = * (int*) malloc (sizeof (int));  /* но это извращение */
int &x4 = *new int; /* точно такое же извращение */
int &x5 = x1; /* можно инициализировать и через другую ссылку */

Ссылку, как только что было сказано, нельзя заставить ссылаться на другой объект. Раз уж она начала на что-то ссылаться, то она будет на это ссылаться до конца жизни. Например, заведём указатель и ссылку и попытаемся их поменять:

int z = 3, zz = 5;
int *pz = &z; /* теперь pz указывает на z,  *pz == z == 3 */
int &x = z; /* теперь x указывает на z,  x == z == 3 */
pz = &zz; /* теперь pz указывает на zz,  *pz == zz == 5 */
x = zz; /* но x ведь указывала на z! значит, мы написали «z = zz».
            теперь x == z == zz == 5 */

Изменить указатель, скрытый за ссылкой x, нельзя — просто нет в Си++ оператора, позволяющего это сделать.

Ещё одно важное отличие — ссылка не может ссылаться «ни на что». То есть, если указатель может иметь значение NULL, то ссылка — нет.

Или может...:

int& g = *(int*)(0);
int* h = &g; /* h == 0 */
g = 2; /* Ошибка выполнения */
  • Как известно, NULL является просто красивым названием для числа 0 (всегда верно для C++, хотя для C зависит от аппаратной платформы), в некоторых реализациях, приведенном к типу void *.

Зачем нужны ссылки?

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

int x;
int *px=&x;
px=0;
*px++; // Ошибка! В предыдущей строке программист забыл написать * перед px

Ссылки становятся по-настоящему необходимы, когда нужно переопределить оператор для класса.

Использование констант

Общие соображения

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

Константы есть и в Си, но их никто не использует, ибо они были кривые. Числовые константы в C делают с помощью #define, и это неплохо работает, хотя и имеет свои минусы. Константы всех остальных типов, в C используют редко.

В Си++ константы и всё, с ними связанное, получило религиозный статус с приходом классов. Но этого мы сейчас касаться не будем. Сейчас мы разберёмся, чем отличаются константы Си++ от констант Си.

…А главное отличие — их теперь можно использовать! Например, можно написать так:

const int N = 10;
int A [N];

Возможно, вы не поверите, но в Си этого сделать было нельзя. Хотя значение N известно во время компиляции, но в Си компилятор «закрывал на это глаза». (Безусловно, у них были свои причины на это, для интересующихся — причины зовут «extern», но всё равно получилось не очень хорошо.)

А раз константы можно использовать, отчего же этого не делать? Они многим лучше, чем #define:

  • они имеют тип, а значит, позволяют «на халяву» найти парочку ошибок в вашей программе;
  • они могут быть не просто числом или строкой, но и какой-нибудь сложной структурой;
  • их имена можно использовать при отладке (хотя с современными средствами это почти не актуально);
  • они не имеют побочных эффектов и других классических проблем с макросами (отсылаю вас к предостережениям о директиве #define, написанных в любой хорошей книге по Си или Си++).

Константы обязательно инициализировать, например:

const int foo = 10; /* можно */
const int bar; /* нельзя */

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

Константы и ссылки/указатели

Константы очень интересно сочетаются с указателями и ссылками. По крайней мере, это интересно выглядит. Посмотрите сами:

const int *foo

или

int const *foo
Указатель на const int. Значение указателя изменить можно (так, чтобы он указывал на что-нибудь другое), а вот значение переменной, на которую он указывает, менять нельзя.
int *const foo = &x
Константный (неизменный) указатель на int. Значение указателя менять нельзя (прям как будто это ссылка, а не указатель). Значение того, на что он указывает, менять можно. Заметьте, что константный указатель обязательно инициализировать, как и любую другую константу.
const int *const foo = &x
Смесь двух предыдущих пунктов. Ничего нельзя изменить: ни значение указателя, ни значение того, на что он указывает. Опять же, инициализация обязательна.

У ссылок разнообразия значительно меньше, ибо «указательная» часть ссылки и так всегда константна. Значит, бывает только:

const int &foo = x
Ссылка на int, который мы (с помощью этой ссылки) не сможем изменить.

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

int &foo = x;
int *const bar = &x;

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

Логический тип и перечисления

Логический тип

В Си классически ложь и истина были просто нулём и не-нулём. (Если хотите — нулём и единицей.) Например, «3 > 2» равно единице; «5 < 3» равно нулю.

В Си++ придумали тип bool, который может иметь два значения — «false» (ложь) и «true» (истина). Это ничего нового не даёт, но делает программы понятнее, потому что в соответствующих местах теперь можно писать «честные» bool, true и false вместо int, 1 и 0.

Безусловно, если вы используете bool в арифметическом выражении, то true станет единицей, а false нулём. Помните, что Си++ почти полностью совместим с Си, поэтому, как правило, старые приёмы работают так же.

Перечислимые типы

Как и константы, они были и в Си, однако использовались даже реже, потому что были ещё кривее. (Некоторые эстеты их всё же использовали, но это ничего не значит.)

Ключевое слово «enum» теперь создаёт новый полноценный тип. Если в Си (стандарт 89ого года (C89)) после

 enum color {red, green, blue};

нужно было всегда называть тип «enum color», то в Си++ и С99 (Си стандарт от 99ого года) его название — просто «color». (Аналогичную метаморфозу претерпели и типы, определяемые ключевыми словами «struct» и «union».)

Для хранения enum'ов используются целочисленные значения. Без явного указания нумерация начинается с 0 и увеличивается на 1. Если вы хотите указать значение можете его записать после знака = последующие значения будут увеличиваться начиная с этого индекса

enum color {red, green, blue};
cout<<red<<' '<<blue<<' '<<green; // "0 2 1"
enum color2 {yellow, cyan=100, magenta};
cout<<yellow<<' '<<cyan<<' '<<magenta; // "0 100 101"

Как вы могли заметить использовать имена записанные в enum'е можно как константы.

Главное отличие перечислений Си++ от Си

1) В стандарте 0x появятся "strongly typed enums"

   enum class E{value1=100,value2,value3};
   ...
   ...
   if(E::value1==100){  // не будет конвертироваться в int
   // этого можно добиться с помощью
   enum class E:unsigned int{value1=100,value2,value3};

Статические массивы: что да как?

Массив является одним из ключевых понятий программирования. Итак, массив - это несколько значений одного типа, расположенных в памяти рядом. Зная местоположение первого из этих элементов, можно обратиться к любому из них. Порядковый номер элемента в массиве называется индексом.

Объявление массивов на Си

Массивы - это та часть языка Си, которая не подверглась изменениям при эволюционировании языка в C++. Поэтому их объявление и работа с ними на обоих этих языках совпадает. Чтобы создать в памяти новый массив, используется такая запись:

int m[10];

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

void MyFunc( int *arr );
MyFunc(m);

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

int arr[45]
void MyFunc( int *arr );    // компилятор посчитает
void MyFunc( int arr[] );   // эти три объявления
void MyFunc( int arr[15] ); // абсолютно идентичными

Итак, ещё раз: в языках Си и C++ типы "указатель" и "массив" очень похожи. Разница лишь в том, что при создании массива происходит автоматическое выделение памяти, в то время как вместе с указателем никакой дополнительной памяти не выделяется. При вызове функций массивы и указатели взаимозаменяемы.

Но при всём том, массив и указатель — это различные типы. Так, если написать в одном файле

int a[10];

а в другом

extern int *a;

- компилятор, скорее всего, выдаст Вам ошибку.

Инициализация

Если создаётся глобальный массив, то изначально все его элементы по умолчанию нули, однако зачастую возникает необходимость присвоить им другие начальные значения. Процесс присваивания начальных значений и называется инициализацией. В C/C++ инициализация осуществляется с помощью знака =, который пишется после имени переменной. Этот знак не есть оператор присваивания, поэтому если Вы будете таким образом инициализировать экземпляры классов, то будет вызван копирующий конструктор, а не функция operator=.

double dbl = 1.0; /* инициализация простой переменной */
double *m[5] = {NULL, NULL, &dbl}; /* инициализация массива указателей */

Как видно из данного примера, значения для инициализации массива пишутся через запятую, причём вся группа значений берётся в фигурные скобки. Если указано n значений для инициализации, и n меньше числа элементов в массиве, то первые n элементов инициализируется согласно списку значений, а остальные эементы становятся нулями. То есть, массив m после инициализации будет выглядеть таким образом: NULL(0), NULL(0), &dbl, 0(NULL), 0(NULL).

Кроме указанного способа, массив символов можно инициализировать непосредственно с помощью строкового литерала:

char str[10] = "Aspid";

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

Наконец, инициализация массива позволяет избежать явного объявления размера. Массив автоматически будет создан такого размера, сколько элементов содержится в списке инициализации:

int m[] = {2, 4, 6}; /* создаётся массив из трёх элементов */
char c[] = "Sample String"; /* создаётся массив содержащий строку, размерность подсчитывается автоматически */

Использование массивов

Предположим, у нас имеется массив m (или, что то же самое, указатель на его начало). Как нам обратиться к самому первому его элементу? Ко второму? К (k + 1)-му? Правильно, так:

*m
*(m + 1)
*(m + k)

Вот это число, которое прибавляется к указателю m, и есть индекс элемента в массиве. В языках C/C++ индексация массива начинается с нуля, поэтому самый первый элемент массива всегда имеет индекс 0. К счастью, язык предоставляет гораздо более удобное средство обращения к элементу с индексом k, а именно, квадратные скобки:

m[k] = 17;

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

k[m] = 17;
0[&x] = x + x; // контрольный вопрос : что делает эта строчка?

Правда, я ещё ни разу не видел, чтобы такая экзотическая запись где-нибудь использовалась.

Конечно, ничто не в силах нам запретить обратиться к элементу 20-элементного массива с индексом 138, равно как и к минус первому элементу. Всё дело в том, что язык не располагает встроенными средствами проверки выхода за границы массива. Предполагается, что пользователь должен самостоятельно следить за тем, чтобы не вылезти за границы массива.

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

sizeof(m) / sizeof(m[0])

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

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

int* a=new int [10];
for(int i=0;i<10;i++)
{ a[i]=random(25);
}
//...
delete []a;

Многомерные массивы

Одна из самых приятных особенностей языка - возможность создавать массив из массивов (т.н. двухмерные массивы), из таких массивов собрать ещё один массив (тогда получится трёхмерный массив) и т.д. Интуитивно понятно, как это делается:

int m[5][8];

Такой код генерирует массив из 5 элементов, каждый из которых является массивом из 8 элементов типа int. Можно обратиться к любому из 5 подмассивов непосредственно (m[3]), либо к конкретному элементу конкретного подмассива (m[3][7]). Первый случай, естественно, проходит только для чтения, поскольку m[3], на самом деле, не является LValue, т.е. объектом, имеющим физический адрес.

Операторы управления динамической памятью

При написании серьезных проектов всегда возникает необходимость выделить дополнительный кусок памяти. Динамическая память - это отнюдь не "барство дикое", а необходимый инструмент. Просто зачастую (например, если мы описываем деревья или списки) изначально нам неизвестно, сколько ячеек памяти может понадобиться. На самом деле, такая проблема была на всем протяжении существования науки/искусства программирования, поэтому неудивительно, что ещё в Си были функции для динамической работы с памятью.

Как это делалось в старом добром Си

В Си для этого было две главных функции: одна называлась malloc() и выделяла непрерывный кусок памяти, другая, free(), этот кусок освобождала. Вот как выглядит код на Си для работы с динамической памятью (она на жаргоне называется кучей, heap):

int *Piece;
Piece = (int*)malloc(sizeof(int)); /* аргумент функции malloc() - число байт, которые надо выделить */
if (Piece == NULL) /* malloc() возвращает NULL, если не может выделить память */
{
  printf("Ошибка выделения памяти: видимо, недостаточно места в ОЗУ\n")
  return;
}
. . .
free(Piece); /* аргумент free() - указатель на уже ненужный кусок памяти */

Если возникала необходимость выделить память под несколько переменных одного типа, расположенных рядом (то есть под массив), аргумент malloc()'а просто домножали на нужное количество ячеек массива:

int *Piece = (int*)malloc(15 * sizeof(int));

Был, правда, у malloc()'а один "недостаток": выделяя память, он не изменял содержимое ячеек, поэтому там могло оказаться совершенно произвольное значение. С этим боролись либо с помощью специальной функции memset(ptr, c, n) (она заполняет n байт памяти начиная с места, на которое указывает ptr, значением c), либо с помощью calloc()'а. Функция calloc() принимает два параметра: число ячеек массива, под которые надо выделить память, и размер этой ячейки в байтах; делает эта функция следующее: выделяет нужное количество памяти (непрерывный кусок) и обнуляет все значения в нём. Таким образом такой код:

int *Piece = (int*)malloc(15 * sizeof(int));
memset(Piece, 0, 15 * sizeof(int));

эквивалентен такому:

int *Piece = (int*)calloc(15, sizeof(int));

Операторы new и delete

Идеология языка C++ предполагает, что каждый объект создаётся (объявляется) именно в том месте, где он нужен, и является работоспособным сразу после создания. Для этого каждый класс имеет определёный набор конструкторов - функций, которые должны автоматически запускаться при создании объекта (экземпляра данного класса) и инициализировать его члены (data members). Конструкторы одного класса отличаются только количеством и типом передаваемых параметров, то есть явяются перегруженными функциями. Однако, к сожалению, функции malloc() и сalloc() не умеют автоматически запускать конструкторы, и потому непригодны для динамического создания объектов. В языке C++ им имеется адекватная замена - оператор new. Рассмотрим пример:

MyClass *mc = new MyClass(5);

В данном случае создаётся экземпляр класса MyClass, после чего с помощью его конструктора, принимающего в качестве параметра целое число (в данном случае, число 5), объект "инициализируется" этим числом. Адрес вновь созданного объекта присваивается указателю mc. Если для класса определён конструктор по умолчанию, после имени класса допускается не указывать пустые скобки. Писать их или нет - это, как говорится, дело вкуса:

new MyClass(); // эти две строки кода
new MyClass;   // абсолютно эквивалентны

Естественно, ничто не мешает использовать оператор new для простых скалярных переменных (например, целых чисел или других указателей).

Важное отличие оператора new от функции malloc() заключается в том, что он возвращает значение типа "указатель-на-объект" (то есть MyClass *), в то время как функция malloc() - "указатель-на-что-угодно" (void *). Подобная типизация в C++ - не редкость, она строже, чем та, что используется в Си, и, следовательно, менее ошибкоопасна. Извратившись, и в C++ можно скомпилировать код, где указатель на один класс приводится к указателю на другой класс, никак не связанный с первым — но в C++ это можно сделать только специально.

Для каждого класса, помимо конструкторов, определён ещё и деструктор, то есть функция, отвечающая за корректное уничтожение объекта. Деструктор никогда никаких параметров не принимает, и потому не может быть перегружен. Проблема с деструктором возникает та же, что и с конструктором: функция free() не умеет его вызывать. Поэтому в C++ введён ещё один оператор - delete. Синтаксис его очень прост:

delete mc;

где mc - указатель на класс. Именно для этого класса и вызовется деструктор, поэтому, если Вы объявили его как "указатель-на-что-угодно", деструктор не будет вызван вообще. Собственно, именно поэтому void * не рекомендуется использовать. Другой пример:

class Base
{
. . .
};

class Derived : public Base
{
. . .
};

int main( void )
{
  Base *ptr = new Derived; // присваивать указателю на предка адрес потомка - можно, но почти всегда требует виртуального деструктора<!--тут надо бы ссылку внутри книги -->
  . . .
  delete ptr;
  return 0;
}

В этом случае оператором delete вызовется деструктор базового класса Base, хотя требуется вызвать деструктор класса-потомка Derived. Казалось бы, применение RTTI (Run-Time Type Info) в среде Microsoft Visual Studio позволило бы спастись от этой напасти, но увы и ах... В принципе, гибким решением этой пробемы является применение виртуальных деструкторов. Вообще же, как мы видим, при использовании delete надо проявлять особую осторожность.

Диномассивы на C++

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

MyClass *mc = new MyClass[15];

С формальной точки зрения, эта конструкция (называемая оператором new[] (разница в скобках)) не есть оператор new, описанный ранее. В частности, при перегрузке оба эти оператора описываются отдельно друг от друга и вообще никак не связываются.

Итак, в нашем примере будет выделена память под массив из 15 объектов класса MyClass, и каждый из них будет инициализирован с помощью конструктора по умолчанию. Если такой конструктор не определён, то попытка использовать new[] приведёт к ошибке. Применить к элементам созданного динамического массива какой-либо другой конструктор, увы, нельзя; поэтому такая запись вызовет легкое недоумение компилятора:

MyClass *mc1 = new MyClass("hello, world!")[134];
MyClass *mc2 = new MyClass()[2]; // это тоже ошибка, нельзя комбинировать два типа скобок в одном new

В пару к оператору new[] введён оператор delete[]. Система сама помнит, сколько памяти было выделено под этот динамический массив, поэтому указывать число элементов не требуется. Просто напишите:

delete[] mc;

и компьютер сделает всё за Вас. Предостережение при использовании delete[] такие же, как и для delete: если хотите избежать утечек памяти (memory leak), следите за тем, чтобы вызывались "правильные" деструкторы.

TODO : перегрузка операторов new, delete, new[] и delete[] в классах

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

TODO (пропущено)

NOTE: интересно, что здесь имели в виду под «особенностями использования статической памяти»? Уж не модификатор ли static для глобальных переменных и функций?

ADDED: Думаю, скорее имеются в виду переменные и массивы, размещаемые в стеке, в отличие от динамической памяти (куча, heap), которая выделяется при использовании оператора new и/или функции malloc().

Функциональный полиморфизм

Это про перегрузку?! Или про настоящий полиморфизм — но тогда почему он «функциональный», неужели в противоположность SmallTalk'овским сообщениям? (Наш курс ООП вовсе не такой продвинутый!) И почему тогда так рано? Не, видимо, это всего лишь перегрузка. (Тогда я бы «полиморфизмом» её назвал с большой натяжкой, ибо обычно под этим термином понимают «run-time polymorphism», а у нас налицо оный в «compile-time». И всё равно, ни фига это не полиморфизм.)

Итак, напишу пока что про перегрузку.

Перегрузка функций: введение

Этот идиотский термин придумали переводчики, встретив ангийское выражеие «function overloading». Были и нормальные люди (в издательстве «Мир»), которые перевели это как «совмещение функций», и было это 20 лет назад. Однако троечники-переводчики из современных издательств классических книг по программированию, разумеется, не читали, и «перегрузка» стала общеупотребимым термином.

Что же такое перегрузка? В Си может быть только одна функция с одним именем. Например, представим себе семейство функций, которые выводят на экран некоторое значение (число или строку). В Си мы могли бы описать их так:

void print_int (int v);
void print_float (float v);
void print_sz (const char *v);
  
int main () {
  print_int (10);
  print_float (5.5);
  print_sz ("Hello, world!\n");
}

Однако на самом деле, когда мы вызываем «print_нечто (10)», компилятор уже знает, что 10 — это целое число. Но он никак не даёт нам воспользоваться этим знанием, и нам приходится вручную говорить, что печатаем мы именно int: «print_int (10)».

(Если вам интересно, почему функция названа «print_sz», я открою секрет: «sz» расшифровывается как «string-zero» — обычное обозначение для строк, заканчивающихся нулевым символом.)

Си++ как раз позволяет предоставить компилятору задачу по выбору нужного варианта функции. Для этого мы можем определить набор функций, каждая из которых имеет имя «print», но которые принимают разные аргументы. Предыдущий пример тогда будет выглядеть следующим образом:

void print (int v);
void print (float v);
void print (const char *v);
  
int main () {
  print (10);
  print (5.5);
  print ("Hello, world!\n");
}

Как видим, компилятор и сам неплохо справляется с выбором — поэтому не нужно утруждать себя, указывая print_int или print_float.

Правила перегрузки

Они очень просты. Функции должны отличаться количеством или хотя бы типом аргументов. Причём типы должны отличаться принципиально (см. ниже). Это то, что влияет на перегрузку. (Когда вы будете работать с классами, функции-члены классов ещё можно перегружать так: все аргументы имеют одинаковые или похожие типы, но одна функция имеет слово «const» в конце (то есть является константной функцией-членом), а другая — не имеет.)

Не влияет на перегрузку тип возвращаемого значения. Они могут быть одинаковые или разные; на возможность перегрузки это не отразится. Так делать нельзя, хотя иногда и хочется:

/* нельзя! */
int get_item (int index);
const char *get_item (int index);

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

Что же такое «принципиально разные» типы? Рассмотрим следующий неправильный пример:

/* так нельзя! */
typedef char byte;
void print (char v); /* напечатать символ */
void print (byte v); /* напечатать значение байта (НЕ как символ) */

Для вас byte и char — разные вещи, однако для компилятора это одно и то же. Поэтому, хотя они и выглядят по-разному, на самом деле это одинаковые типы. Перегруженные функции не могут отличаться только тем, что аргумент одной имеет тип byte, а соответствующий аргумент другой — тип char.

(Если вы захотите повторить трюк с «typedef … byte» в своей программе, то нужно писать «typedef unsigned char byte», ибо под байтом обычно разумеют число от 0 до 255, а не число от -128 до 127. Кстати, если вы работаете с русскими символами, то при использовании типа «char» они будут иметь отрицательные коды, так что имеет смысл всегда писать «unsigned char» вместо просто «char».)

NOTE: Стандарт Си++ не определяет, будет ли char знаковым или нет — это отдано на откуп реализации. Так что неверно предполагать, что char всегда означает signed char - может быть и unsigned. Если наличие или отсутсвие знака важно (например, при конвертации в int или сравнении больше/меньше), лучше указывать это явно.

Также стоит остерегаться неоднозначности другого вида. Смотрим пример:

void print (char v)
void print (char v, bool uc = true) /* Сигнатуры функций различны, здесь ошибки нет */

/* а где-то в программе ... */
print ('a'); /* Ошибка, т.к. компилятор не может выбрать подходящую функцию */

Константные и неконстантные версии функций с одинаковыми аргументами также различны. Например:

class foo {
  void print (char v);
  void print (char v) const;
}

foo F1;
F1.print('a'); /* используется неконстантная версия print() */

const foo F2;
F2.print('z'); /* используется константная версия print() */

Когда использовать перегрузку

Положительные стороны перегрузки:

  • удобнее писать программу (меньше приходится набирать, меньше думать);
  • меньше кода исправлять, когда тип какой-то переменной меняется.

Отрицательные стороны:

  • более подвержено ошибкам (иногда компилятор может выбрать не ту функцию, которую вы имели в виду, и вам не сказать);
  • некоторые типы, которые вы считаете разными, компилятор сочтёт одинаковыми, и не даст перегрузить по ним.

Пространства имён

Пространства имён как общее понятие

Термин «пространство имён» (namespace) — один из основных в языках программирования.

Давайте проведём аналогию с адресом. Если вы скажете: «Я живу в доме 56», ваше заявление не будет иметь никакого смысла, пока вы не уточните, на какой улице расположен этот дом. На каждой улице могут быть свои номера домов; на разных улицах номера могут повторяться, и будут обозначать совершенно разные дома.

Теперь рассмотрим следующий фрагмент программы на Си (заметьте, сейчас всё, что мы говорим, относится как к Си, так и к Си++):

int x;  /* первая x */
void foo (char x) {  /* вторая x */
  x = 12; /* здесь x -- это вторая x */
}
void bar () {
  x = 12; /* здесь x -- это первая x */
}
int main () {
  double x;  /* третья x */
  x = 12; /* здесь x -- это третья x */
}

В этой программе есть три разных переменные «x». Это возможно потому, что каждая функция имеет своё пространство имён. Имена (в частности, имена переменных), определённые в нём, видны только из этого пространства (то есть — изнутри этой функции).

Также есть глобальное пространство имён. «Первое x» — именно оттуда. Глобальное пространство имён видно везде. Однако, если то же имя объявить в локальном пространстве имён (как мы поступили, определив второе и третье «x»), оно для этого пространства оказывается «ближе к сердцу».

Но это ещё не всё. Каждый блок имеет своё пространство имён. Например:

int main ()
{
  int x, y = 321;
  x = 1;
  {
    int x; 
    x = 2;
    { 
      int x;
      x = 3;
      y = 123;
    }
    x = 4;
  }
  x = 5;
}

Здесь в одной функции объявлено аж три разных икса. Когда внутри блока определяется очередной «x», он скрывает все предыдущие переменные с таким же именем. Но как только блок заканчивается, «его собственный» икс исчезает, и вновь становится доступным икс из родительского блока.

В примере была ещё переменная «y». В отличие от «x», её никто не переопределял, поэтому и в самом вложенном, и во внешнем блоке «y» обозначает одну и ту же переменную.

Пространства имён структур и объединений

В Си++ структуры («struct») и объединения («union») стали создавать полноправные типы данных. Кроме того, каждая структура и объединение (как и, разумеется, каждый класс) получили своё полноправное пространство имён.

Например, вы можете написать:

struct Foo {
  typedef unsigned char byte;
  byte data [16];
};

В структуре определено имя «byte». Но оно «просто так» не видно за пределами данной структуры. Если вы захотите воспользоваться типом «byte» из структуры «Foo», то вот как это говорят в Си++: «Foo::byte». Например:

int main () {
  Foo::byte x;
  x = 17;
}

Двойное двоеточие называется оператором разрешения области видимости, но, я полагаю, не обязательно задумываться об этом названии. Оно используется, чтобы указать «путь», по которому можно добраться до нужного имени.

Заметим, что в таком пространстве имён можно определять всё, что вам заблагорассудится. Например, другие структуры:

struct Foo {
  struct Bar {
    struct Boz {
      int x, y;
    };
  };
};
int main () {
  Foo::Bar::Boz wow;
  wow.x = 7;
  wow.y = 11;
}

Определяемые пользователем пространства имён в Си++

Во всех предыдущих примерах пространства имён были «приложением» к чему-то другому — будь то блок, функция, структура, класс или объединение. Однако в Си++ есть конструкция, которая позволяет создавать пространство имён в чистом виде. Она очень проста:

namespace MyFavouriteTypes {
  typedef unsigned char byte;
}
int main () {
  MyFavouriteTypes::byte data [16];
  
}

Конструкция «namespace» обладает одним уникальным качеством. Пространство имён, ею созданное, можно дополнять в разных местах программы. Пример:

namespace MyFavouriteTypes {
  typedef unsigned char byte;
}
  
namespace MyFavouriteTypes {
  typedef char *string;
  typedef const char *const_string;
}
int main () {
  MyFavouriteTypes::byte data [16];
  MyFavouriteTypes::const_string prompt = "Please enter your name: ";
  
}

Вы должны понимать, что в наших примерах всё записано в одном файле. Однако в реальной жизни разные куски кода могут приходить из разных «.h»-файлов. Вот небольшой пример:

mytypes.h
namespace MyFavouriteTypes {
  typedef unsigned char byte;
}
histypes.h
namespace MyFavouriteTypes {
  typedef char *string;
  typedef const char *const_string;
}
main.cpp
#include "mytypes.h"
#include "histypes.h"

int main () {
  MyFavouriteTypes::byte data [16];
  MyFavouriteTypes::const_string prompt = "Please enter your name: ";
  
}

Как и всегда, стоит ли говорить, что пространства имён могут быть вложенными? Например:

namespace Foo {
  namespace Bar {
    namespace Boz {
      int z;
    }
  }
}
int main () {
  Foo::Bar::Boz::z = 17;
}

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

var.h
namespace Foo {
  namespace Bar {
    namespace Boz {
      extern int z;
    }
  }
}
var.c
#include "var.h"
int Foo::Bar::Boz::z = 0;
user1.c
#include "var.h"
void func1 () {
  Foo::Bar::Boz::z = 1;
}
user2.c
#include "var.h"
void func2 () {
  Foo::Bar::Boz::z = 2;
}

В файле «var.h» переменная «Foo::Bar::Boz::z» только объявляется, то есть вы заявляете, что она существует где-то. В файле «var.c» эта переменная определяется, то есть вы заявляете, что в данном месте нужно отвести память для её хранения.

Если бы мы определили переменную сразу в файле «var.h» (не написав слово «extern»), память под неё была бы отведена в каждом файле, в который включается «var.h». В нашем случае — память была бы выделена два раза. Но как у одной переменной может быть две разных области памяти? Никак. И редактор связей выдаст вам соответствующую ошибку: «Идентификатор Foo::Bar::Boz::z определён несколько раз».

Ключевое слово using

TODO Написать о using в контексте класса, и другого пространства имён.

Ну, «using» — это совсем просто. Возможно, вам показалось подозрительным всегда использовать названия вроде «MyFavouriteTypes::byte». И правда, есть способ их подсократить. Выглядит он так:

mytypes.h
namespace MyFavouriteTypes {
  typedef unsigned char byte;
}
main.c
#include "mytypes.h"
using namespace MyFavouriteTypes;
int main () {
  byte data [16];
  
}

С помощью директивы «using namespace» вы говорите, что «дальше я буду ссылаться на такое-то пространство имён без указания его имени». Эту директиву можно написать и внутри функции:

#include "mytypes.h"
int main () {
  using namespace MyFavouriteTypes;
  byte data [16];
  
}

Тогда её действие распространяется только на эту функцию.

Помимо «using namespace», есть и оператор «using». Он позволяет использовать одно какое-нибудь имя, не трогая другие. Например:

#include <iostream>
using std::cout;
using std::endl;
int main () {
  cout << "Hello, world!" << endl;
}

Здесь мы не стали изобретать своих собственных типов, а использовали стандартную библиотеку Си++. Все имена из стандартной библиотеки находятся в пространстве имён «std». Многие пишут «using namespace std» и радуются, но, возможно, пример, приведённый выше, демонстрирует несколько лучший стиль программирования.

Внимание, правило. Никогда не пишите «using» в заголовочных («.h») файлах. Это лишает тех, кто их включает (#include), возможности самим решать, нужно ли им «using» или они хотят использовать полное имя.

Исключения

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

Библиотека ввода-вывода (iostream)

TODO (пока скипнуто, видимо будет)


Средства объектно-ориентированного программирования

Основными механизмами в объектно-ориентированном программировании есть: -полиморфизм; -наследование; -инкапсуляция.

Инкапсуляция - объединение переменных и функций (свойств и методов) в классе.

Наследование - помещение в производный класс (который наследует) свойств и методов базового класса (которого наследуют).

Полиморфизм - вызов виртуальной функции производного класса через указатель на базовый.

Обобщённое программирование (Generic programming)

Введение, или альтернативные подходы

Предположим, мы написали суперконтейнер (то есть тип, предназначенный для хранения) чисел типа int:

class IntContainer {
public:
  void add (int value);
  int index_of (int value);
  int get_count ();
  int get_value (int index);
private: 
    
};

Однако нам грустно тратить силы, создавая суперконтейнеры для каждого типа данных, который мы собираемся использовать. Тем более, что их код будет похож, как две капли воды. Что же делать?

Есть старый приём, использовавшийся в Си. Он оригинален и неожидан, как и всё в Си. Применительно к нашей ситуации, он выглядит так (разумеется, в Си классов не было, и поэтому там всё выглядело иначе):

#define DEFINE_CONTAINER(T,N) \
  class N ## Container { \
  public: \
    void add (T value); \
    int index_of (T value); \
    int get_count (); \
    T get_value (int index); \
  private: \ 
       \
  };

Это — длинное определение макроса «DEFINE_CONTAINER». Оно занимает несколько строк благодаря использованию «символа продолжения макроса на следующую строку» — «\». Символы «##» используются, чтобы соединить вместе аргумент «N» и слово «Container», получив одно слово — название определяемого класса.

Используется это так:

DEFINE_CONTAINER (int, Int)
IntContainer apples;
int main () {
  apples.add (11);
  apples.add (13);
}

Строка «DEFINE_CONTAINER (int, Int)» раскрывается в определение класса «IntContainer», который мы и используем далее. Если нам понадобится контейнер для другого типа, мы можем написать «DEFINE_CONTAINER (double, Double)», «DEFINE_CONTAINER (const char *, ConstString)». Получатся классы «DoubleContainer» и «ConstStringContainer».

Можно обойтись и без макросов. Мы могли бы создать файлы «FooContainer._h_» и «FooContainer._cpp_». В их можно было определить контейнер «FooContainer» для типа «foo». Далее, вы используете ваш любимый текстовый редактор, чтобы скопировать их, например, в «IntContainer.h» и «IntContainer.cpp», заменив все вхождения слов «Foo» и «foo» на «Int» и «int» соответственно. Этот процесс можно автоматизировать. В нужный момент вы просто включите файл «IntContainer.h» и будете им пользоваться, как будто он написан вручную.

То, чем вы занимаетесь при любом из этих подходов, можно назвать словом «generic programming», или просто программирование шаблонов. (Вполне понятно, почему «DEFINE_CONTAINER» и «FooContainer._h_/._cpp_» можно назвать шаблонами контейнеров.)

Однако, в Си++ есть родной (встроенный) способ создавать шаблоны, без некрасивых макросов и неудобных манипуляций с файлами.

Шаблоны классов

Шаблоны в Си++ создаются с помощью ключевого слова «template». Предыдущий пример можно переписать в виде:

template <typename T>
class Container {
public:
  void add (T value);
  int index_of (T value);
  int get_count ();
  T get_value (int index);
private: 
   
};

Мы имеем шаблон, называемый «Container». Этим шаблоном, как и любым другим, в чистом виде пользоваться нельзя; пользоваться можно только его экземплярами для конкретных типов.

В Си++ экземпляр шаблона «Container» для типа «int» будет называться «Container<int>»; угловые скобки и всё между ними следует рассматривать как часть названия класса. (Хотя, безусловно, вы можете вставлять пробелы по вкусу, например «Container < int >».)

Си++ сам создаёт нужные экземпляры шаблона, то есть никаких «DEFINE_CONTAINER» и тому подобных вещей вы не пишете вообще. Вы просто пользуетесь нужными экземплярами шаблонов. Например, мы можем написать:

Container <int> apples;
int main () {
  apples.add (7);
  apples.add (11);
}

Что ещё нужно вам узнать прямо сейчас?

  • В чистом виде название «Container» не используется почти ни в каких случаях. (Исключения упомянем позже.) Всегда используется или «template <typename T> class Container», если мы говорим о шаблоне в общем, или же «Container <int>», если мы говорим о конкретном экземпляре шаблона.
  • Безусловно, у шаблонов может быть несколько параметров.
  • Параметрами шаблонов могут быть не только типы. Например, вы можете описать шаблон «template <typename T, unsigned size> class Array», и воспользоваться его экземпляром «Array <float, 20>».

Шаблоны функций

Бывает, что нет смысла городить «целый класс», и шаблона одной-единственной функции вам вполне достаточно. Например, напишем функцию «swap», которая меняет местами значения переданных аргументов.

Вот как она выглядит на Си++:

template <typename T>
void swap (T &left, T &right) {
  T temp = left;
  left = right;
  right = temp;
}

Аналогично шаблонам классов, это — шаблон функции. (Часто говорят — «шаблонный класс», «шаблонная фукнция».) Экземпляр этой функции, обменивающий местами два значения типа «int», называется «swap <int>».

Однако, в отличие от классов, при вызове функций обычно предоставляют компилятору право выяснить параметры шаблона. Вот так:

int main () {
  int a = 3, b = 5;
  swap (a, b);
  /* теперь a = 5, b = 3 */
}

Поскольку оба аргумента функции имеют тип «int», компилятор знает, что нужно вызвать «swap <int> (a, b)». Разумеется, всегда можно указать экземпляр функции вручную, самостоятельно вызвав «swap <int> (a, b)».

Во всём остальном шаблоны функций не отличаются от шаблонов классов. Поэтому дальнейшее обсуждение относится к ним обоим.

Параметры шаблонов

Вы уже знаете почти всё о параметрах. Осталось сказать только самую малость.

Параметры, как вы уже знаете, бывают двух видов:

  • типы;
  • числа, строки и другие простые типы.

Теперь о них поподробнее.

«typename» и «class»

Чаще всего применяются, конечно, параметры-типы. Есть два совершенно равноценных способа сделать такой параметр:

/* первый способ */
template <typename T> class Foo {  };
/* второй способ */
template <class T> class Foo {  };

Об этом не стоило бы и говорить. Слова «typename» и «class» в этом месте — полные синонимы. Видимо, предпочтительнее использовать «typename». На самом деле, в Си++ слова «тип» и «класс» — почти всегда синонимы.

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

template <typename T>
class Foo {
  T boz;
  void bar () {
    boz.someCoolMethod (15);
  }
};

то явно предполагается, что у переданного типа есть метод «someCoolMethod», принимающий один аргумент типа «int». Но компилятор об этом не знает, поэтому в качестве параметры вы всё равно можете передать любой тип. Однако, когда компилятор попытается создать экземпляр шаблона, он будет ругаться: «У типа T не определён метод someCoolMethod!» (и дальше ещё длинная информация о том, что он создаёт экземпляр шаблона, определённого там-то, с такими-то параметрами).

Применений у параметров шаблона, являющихся типами, — тьма. Вот самое обычное применение:

template <typename T>
class Foo {
  T boz;
    
}

Вот — необычное применение:

template <typename T>
class Foo : public T {
    
}

Простые типы как параметры шаблона

Параметры в виде простых типов используются сравнительно реже. Выглядят они так:

template <unsigned BufSize>
class Foo {
  char buffer [BufSize];
};

TODO: какие именно типы можно использовать в качестве параметров? Интегральные.

TODO: найти ещё примеры применения этого вида параметров. static assert.

Значения по-умолчанию у параметров шаблона

Шаблоны могут иметь значения по-умолчанию для некоторых параметров. Это очень похоже на аналогичную возможность для функций. Пример:

template <typename ValueType, unsigned, unsigned MaxSize = 100>
class Foo {
public:
  int add (ValueType value);
  int index_of (ValueType value);
  int get_count ();
  ValueType get_item (int index);
protected:
  ValueType data [MaxSize];
  unsigned count;
};

Здесь мы имеем контейнер, в котором может храниться до «MaxSize» объектов типа «ValueType». Если мы не укажем параметр «MaxSize» при создании экземпляра, то будет использовано значение по-умолчанию — 100. (Заметьте, что это ужасно плохой стиль программирования. Пользователь вашего класса может встроить в свою программу ограничение в 100 объектов, даже не подозревая об этом. Никогда так не делайте.)

Параметры-типы тоже могут иметь значения по-умолчанию. Например, в STL повсеместно применяются объекты-аллокаторы. Однако вы чаще всего о них не задумываетесь — именно благодаря значениям по-умолчанию. Например, класс «list» из STL может иметь такой заголовок:

template <typename ValueType, typename AllocatorType = allocator <ValueType> >
class list {
    
};

Значением по-умолчанию для параметра «AllocatorType» является тип «allocator <ValueType>». Заметьте, как мы используем значение одного параметра («ValueType»), чтобы задать значение по-умолчанию для другого параметра. Это возможно, потому что второй параметр описан после первого. Вы везде пишете:

int main () {
  list <int> oranges;
  oranges.push_back (11);
  oranges.push_back (13);
}

и можете вообще не знать, что такое аллокатор. Но самые продвинутые могут написать свой аллокатор, и использовать экземпляр «list» по имени «list <int, MyAllocator>».

Синтаксические особенности шаблонов

До сих пор мы не касались того, что скрывается за троеточиями в многочисленных примерах кода. Между тем, чтобы вы могли написать законченный шаблон, вам нужно узнать несколько вещей.

Определение функций-членов (методов)

Как и всегда, определить небольшой метод можно и внутри класса:

template <typename T>
class Foo {
  void bar () { 
      
  }
};

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

template <typename T>
void Foo <T>::bar () { 
    
}

Он может показаться ужасным, но иного выхода нет. Кроме того, если вы класс определяете в заголовочном файле, то и все определения его методов вы должны поместить в заголовочный файл, а не в соответствующий «.cpp». (Как правило, у шаблонных классов вообще нет «.cpp»-файла, весь код помещается в «.h»-файл.)

Конструктор и деструктор

Конструктор и деструктор определяются также, как и обычные методы:

template <typename T>
Foo <T>::Foo () { 
    
}

template <typename T>
Foo <T>::~Foo () { 
    
}

Заметьте, имена конструкторов и деструктора — немногие случаи, когда имя класса употребляется «в чистом виде».

Определение статических данных-членов

Если вы знаете, о чём идёт речь, то следующий пример скажет вам всё необходимое:

template <typename T>
class Foo {
public:
    static int x;
};

template <class T>
int Foo <T>::x;

Экземпляр шаблона как параметр другого шаблона

Мы уже видели пример того, как это делается, выше, в разделе «Значения по-умолчанию для параметров». Немного другой пример:

template <typename T>
class Foo {  };

template <typename T>
class Bar {  };

int main () {
  Bar <Foo <int> > bar;
}

Единственный важный момент, который нужно знать: между закрывающими угловыми скобками обязательно должен быть пробел («> >»). Иначе компилятор будет считать их оператором поразрядного сдвига «>>», и выдаст много малопонятных ошибок.

Шаблон как параметр другого шаблона

В качестве параметра шаблона можно также передавать другой шаблон:

template< template< class T > class U >
class Foo {
 public:
   U< int > member;
};

Это можно использовать, например, для того, чтобы указать алгоритм, используемый шаблонным классом, или стратегию хранения:

#include <vector>  
/*
    Шаблон из стандартной библиотеки C++
  template<class Type, class Allocator = std::allocator<Type> 
  class vector
  {
    ... ...
  }
*/

#include <list>
/*
    Шаблон из стандартной библиотеки C++
  template<class Type, class Allocator = std::allocator<Type> 
  class list
  {
    ... ...
  }
 */

template< 
    template<class Type, class Allocator = std::allocator<Type> > class Container, 
    class T 
>

class Bar {
    ... ... ... 
    Container<T> collection;
    ... ... ... 
};

class Qux
{
   ... ... ... 
};

Bar< std::vector, int >  bar1;  // bar1.collection имеет тип std::vector<int>
Bar< std::list,   Qux >  bar2;  // bar2.collection имеет тип std::list<Qux>

Теперь bar1 использует для хранения int std::vector, а bar2 - std::list для хранения Qux ( std::vector и std::list - шаблоны из стандартной библиотеки C++. Обратите внимание, что они имеют два шаблонных параметра, один из которых задан в стандартной библиотеке по умолчанию, и мы при написании шаблонного параметра шаблона должны указать их оба ).

Ключевое слово «typename»

Рассмотрим класс:

struct Foo {
  typedef int my_type;
};

Вся суть этого класса в том, что «Foo::my_type» обозначает некий тип. Теперь напишем шаблонный класс, использующий этот факт. (Внимание, пример неправильный!)

template <typename T>
class Bar {
  T::my_type x;
};

Мы пытаемся воспользоваться типом «my_type», определённым в «T». Однако в силу некоторых причин компилятор сам не хочет понимать, что «T::my_type» в данном случае обозначает тип. Мы должны ему в этом помочь, поставив перед всеми такими типами ключевое слово «typename»:

template <typename T>
class Bar {
  typename T::my_type x;
};

В этом фрагменте кода утверждается, что «T::my_type» — это именно тип. На современных компиляторах всегда, ссылаясь на тип, определённый внутри класса, передаваемого параметром шаблона, необходимо использовать «typename».

Заметьте, все определения методов шаблонного класса должны быть видны изо всех мест, где он используется. Это значит, что если вы, например, попытаетесь использовать механизм раздельной компиляции, включая в заголовочный файл(*.h) только обявление шаблона, то компилятор просто не сможет его инстанцировать, т.ё. вывести из шаблона конретный тип. Поэтому приходится включать в заголовочные файл(или в единицу трансляции где шаблон объявлен и используется) помимо объявления ещё и определение шаблона, что приводит к разбуханию кода. Существует решение этой проблемы в виде механизма экспорта шаблона, предложенного компанией Silicon Graphics и в своём виде поддерживаемого некоторыми компиляторами различных разработчиков. Однако это решение потребовало изменение стандарта Си++ 1996г. и нашло реализацию далеко не во всех компиляторах, например в компиляторе от Microsoft включённого в продукт Microsoft Visual Studio.

Особенности компиляции шаблонов

Помещаем шаблоны в файлы

Проверка ошибок при работе с шаблонами

Специализация

Наследование и шаблоны

Стандартная библиотека

STL

Общие сведения

Контейнеры. Итераторы. Функторы. Предикаты.

Контейнеры - это template-ы классов, поддерживающих стандартные системы организации данных, таких как массив, односвязный и двусвязный списки и т.п. По сути это оболочка над контейнером для которого перегружены операторы: ++, *, -- и может быть другие.

Соответственно итератор - это класс, предназначенный для перебора элементов контейнера.

Функторы и предикаты используются в библиотеке алгоритмов STL

Функтор - объект класса в котором перегружен оператор (). Количество аргументов определяется задачей для которой нужен функтор.

Предикат - функция, возвращающая bool, также это может быть функтор оператор () которого возвращает bool. Унарный предикат - предикат принимающий 1 аргумент, к примеру !a. Бинарный предикат - предикат, принимающий 2 аргумента, примеры: a>b, a<b и др.

Контейнеры

Виды. Последовательные и ассоциативные. Адаптеры (??!, что это?).

Итераторы

Обобщение указателя. Классы итераторов.

Алгоритмы

Алгоритмы над контейнерами позволяют в независимости от типа контейнера работать с его данными с помощью итератора контейнера. Ниже приведены некоторые примеры из библиотеки алгоритмов STL, для ее подключения нужно указать #include <algorithm>. Каждый алгоритм в это библиотеке описывает целевую задачу и поэтому очень мал, поэтому советую не лениться и смотреть определение неизвестных классов и функций.

for_each

Код этой функции очень прост (хотя и по началу непонятен т.к. записан в библиотеке ужасным образом в плане эстетизма записи кода):

template<class _II, class _Fn> inline 
_Fn for_each(_II _F, _II _L, _Fn _Op)
{
  for (; _F != _L; ++_F)
  _Op(*_F);
  return (_Op); 
}

рассмотрим аргументы:

_F - итератор на элемент контейнера, с которого нужно начинать

_L - итератор на элемент контейнера, которым нужно заканчивать

_Op - функция от одного аргумента или функтор, у которого перегружена операция () для одного аргумента. Тип аргумента должен соответствовать типу данных содержащихся в контейнере.

Как видим, функция for_each всего-то-навсего для каждого элемента в контейнере вызывает функцию или оператор функтора. Также видно, что возвращаемое значение не играет роли. И все. Пример использования:

//наш функтор
class FunctorToAdd
{
public:
  void operator()(int& zn)
  {
  //чегото делаем с аргументом
  ++zn;
  }
};
 
void main()
{
  vector<int> mass;
  mass.push_back(1);
  mass.push_back(2);
  mass.push_back(3);

  //заметим что мы создаем объект класса FunctorToAdd с помощью возвращающего конструктора
  for_each(mass.begin(),mass.end(),FunctorToAdd());

  cout<<mass[0]<<endl;
  cout<<mass[1]<<endl;
  cout<<mass[2]<<endl;
}

В данной задаче (увеличение каждого эелемента на 1) можно обойтись и простой функцией:

void FuncToAdd(int& zn)
{
  ++zn;
}
 
for_each(mass.begin(),mass.end(),FuncToAdd);

но в более сложных задачах может пригодится объект

transform

Тут проще посмотреть на определение:

template<class _II, class _OI, class _Uop> inline
_OI transform(_II _F, _II _L, _OI _X, _Uop _U)
{
  for (; _F != _L; ++_F, ++_X)
  *_X = _U(*_F);
  return (_X); 
}

transform помещает в новый контейнер (_X - итератор на его начало) значения, которые вернет наша функция (_U) или функтор от аргумента из исходного контейнера. Если указать источник равный приемнику, то будут соответственно изменены значения исходного контейнера. Пример использования:

//нужно создать другую функцию т.к. в transform используется возвращаемое значение
int FuncToAdd2(int zn)
{
  return ++zn;
}

list<int> mass2;
transform(mass.begin(),mass.end(), back_inserter( mass2 ), FuncToAdd2 );

back_inserter - функция которая возвращает объект (back_insert_iterator), который в свою очередь определяет оператор * (возвращает *this) и оператор = в котором вызывает push_back для нашего контейнера mass2. Понятно в чем фишка? Это как раз нам и нужно т.к. mass2 у нас пуст, а в функции transfrom как раз выполняется присваивание.

count_if

Подсчитывает количество элементов в указанном диапазоне контейнера для которых выполняется унарный предикат (третий аргумент). К примеру если у нас массив mass2=[0,1,2,3,4] то:

bool Count(int z)
{
  return z>2;
}
 
//вернет 2
cout<< count_if(mass2.begin(),mass2.end(),Count) <<endl;

Ниже будет показан пример как этого же результата можно добится используя адаптеры вместо функции.

Адаптеры

Для работы нам потребуется добавить #include <functional>

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

 cout<< count_if(mass2.begin(),mass2.end(), bind2nd(greater<int>(),2)) <<endl;

Страшно? Ну ничего, сейчас все объясню. greater - класс в котором перегружен оператор () в котором происходит сравнение 2-ух аргументов ( т.е. return (_X > _Y) ). Т.е. это настоящий бинарный предикат. А теперь давайте представим, как было бы хорошо, если бы на место первого аргумента (_X) подставлялся элемент из массива, а во второй какое ни будь заданное нами число (2). bind2nd как раз это и делает. При его конструировании мы указываем бинарный предикат и значение, которое сохраняется и подставляется в greater<int>::operator() при вызове bind2nd::operator(). Тем самым мы превращаем наш бинарный предикат в унарный. Что и требуется для функции count_if.

Для тех кто не понял bind2nd и есть адаптер. Существует также bind1st который заменяет не второй аргумент, а первый. По экспериментируйте.

Библиотека ввода-вывода

Потоки вывода

Вывод пользовательских типов.

Потоки ввода

Ввод пользовательских типов.

Форматирование

Буферизация

Разные вопросы

В чём великий смысл «volatile»?

Напишем следующую программу:

int x = 0;
int main () {
  while (x < 10) {
  }
}

Как несложно заметить, она работает вечно. Более того, она полностью загружает весь ваш процессор очень сложными вычислениями…

Что с ней сделает хороший компилятор? Он заметит, что значение переменной «x» внутри цикла не изменяется. Поэтому он просто не будет каждый раз читать значение «x» из памяти и сравнивать его с 10. Зачем? Какое значение «x» было при входе в цикл, таким оно и останется. Поэтому он превратит ваш код в нечто похожее на:

int x = 0;
int main () {
  if (x < 10) {
    while (1) {
    }
  }
}

Замечательно! Но предположим, что есть ещё кто-то. Этот кто-то вашу переменную «x» каждую секунду увеличивает на единицу. Поэтому ваш первоначальный код на самом деле должен был через 10 секунд завершаться! Но компилятор-то этого не знал, и сделал из неё бесконечный цикл.

Для того, чтобы сообщить компилятору о присутствии кого-то, и служит ключевое слово «volatile». Если переписать пример в виде:

volatile int x = 0;
int main () {
  while (x < 10) {
  }
}

то компилятор при каждом обращении к «x» будет честно заново считывать её из памяти. Ведь её значение могли изменить некие тёмные силы.

NB: Хорошим примером такого рода тёмных сил является другой процесс, имеющий с вашим общую память.

NB1: Вообще говоря, описанная в этом параграфе 100%-я загрузка процессора бессмысленной деятельностью является хорошим примером дурного тона в программировании.

Эпилог

Некоторые люди интересовались, откуда берутся так часто употребляемые метасинтатические переменные foo, bar и тому подобные. На английском происхождение данных слов очень хорошо описано в RFC 3092. (Также, если вы знаете английский, см. полный список первоапрельских RFC.)

Если Вы возьмётесь править этот викиучебник, пожалуйста, соблюдайте правила русской типографики. О том, как это делается см. w:Википедия:Специальные символы.

См. также

Ссылки