Все широко распространенные языки программирования предоставляют единый набор средств, отличающийся лишь специфическими подробностями конкретного языка. Понимание подробностей того, как язык предоставляет эти средства, является первым шагом к овладению данным языком. К наиболее фундаментальным из этих общих средств относятся приведенные ниже.
• Встроенные типы данных (например, целые числа, символы и т.д.).
• Переменные, позволяющие присваивать имена используемым объектам.
• Выражения и операторы, позволяющие манипулировать значениями этих типов.
• Управляющие структуры, такие как if или while, обеспечивающие условное и циклическое выполнение наборов действий.
• Функции, позволяющие обратиться к именованным блокам действий.
Большинство языков программирования дополняет эти основные средства двумя способами: они позволяют программистам дополнять язык, определяя собственные типы, а также использовать библиотеки, в которых определены полезные функции и типы, отсутствующие в базовом языке.
В языке С++, как и в большинстве языков программирования, допустимые для объекта операции определяет его тип. То есть оператор будет допустимым или недопустимым в зависимости от типа используемого объекта. Некоторые языки, например Smalltalk и Python, проверяют используемые в выражениях типы во время выполнения программы. В отличие от них, язык С++ осуществляет контроль типов данных статически, т.е. соответствие типов проверяется во время компиляции. Как следствие, компилятор требует сообщить ему тип каждого используемого в программе имени, прежде чем оно будет применено.
Язык С++ предоставляет набор встроенных типов данных, операторы для манипулирования ими и небольшой набор операторов для управления процессом выполнения программы. Эти элементы формируют алфавит, при помощи которого можно написать (и было написано) множество больших и сложных реальных систем. На этом базовом уровне язык С++ довольно прост. Его потрясающая мощь является результатом поддержки механизмов, которые позволяют программисту самостоятельно определять новые структуры данных. Используя эти средства, программисты могут приспособить язык для собственных целей без участия его разработчиков и необходимости ожидать, пока они удовлетворят появившиеся потребности.
Возможно, важнейшим компонентом языка С++ является класс, который позволяет программистам определять собственные типы данных. В языке С++ такие типы иногда называют "типами класса", чтобы отличить их от базовых типов, встроенных в сам язык. Некоторые языки программирования позволяют определять типы, способные содержать только данные. Другие, подобно языку С++, позволяют определять типы, в состав которых можно включить операции, выполняемые с этими данными. Одна из главных задач проекта С++ заключалась в предоставлении программистам возможности самостоятельно определять типы данных, которые будут так же удобны, как и встроенные. Стандартная библиотека языка С++ использует эту возможность для реализации обширного набора классов и связанных с ними функций.
Первым шагом по овладению языком С++ является изучение его основ и библиотеки — такова тема части I, "Основы". В главе 2 рассматриваются встроенные типы данных, а также обсуждается механизм определения новых, собственных типов. В главе 3 описаны два фундаментальных библиотечных типа: string (строка) и vector (вектор). В этой же главе рассматриваются массивы, представляющие собой низкоуровневую структуру данных, встроенную в язык С++, и множество других языков. Главы 4-6 посвящены выражениям, операторам и функциям. Завершается часть главой 7 демонстрирующей основы построения собственных типов классов. Как мы увидим, в определении собственных типов примиряется все, что мы изучили до сих пор, поскольку написание класса подразумевает использование всех средств, частично раскрытых в части I.
Глава 2
Переменные и базовые типы
Типы данных — это основа любой программы: они указывают, что именно означают эти данные и какие операции с ними можно выполнять.
У языка С++ обширная поддержка таких типов. В нем определено несколько базовых типов: символы, целые числа, числа с плавающей запятой и т.д. Язык предоставляет также механизмы, позволяющие программисту определять собственные типы данных. В библиотеке эти механизмы использованы для определения более сложных типов, таких как символьные строки переменной длины, векторы и т.д. В этой главе рассматриваются встроенные типы данных и основы применения более сложных типов.
Тип определяет назначение данных и операции, которые с ними можно выполнять. Например, назначение простого оператора i = i + j; полностью зависит от типов переменных i и j. Если это целые числа, данный оператор представляет собой обычное арифметическое сложение. Но если это объекты класса Sales_item, то данный оператор суммирует их компоненты (см раздел 1.5.1).
2.1. Простые встроенные типы
В языке С++ определен набор базовых типов, включая арифметические типы (arithmetic type), и специальный тип void. Арифметические типы представляют символы, целые числа, логические значения и числа с плавающей запятой. С типом void не связано значений, и применяется он только при некоторых обстоятельствах, чаще всего как тип возвращаемого значения функций, которые не возвращают ничего.
2.1.1. Арифметические типы
Есть две разновидности арифметических типов: целочисленные типы (включая символьные и логические типы) и типы с плавающей запятой.
Размер (т.е. количество битов) арифметических типов зависит от конкретного компьютера. Стандарт гарантирует минимальные размеры, перечисленные в табл. 2.1. Однако компиляторы позволяют использовать для этих типов большие размеры. Поскольку количество битов не постоянно, значение одного типа также может занимать в памяти больше или меньше места.
Таблица 2.1. Арифметические типы языка С++
Тип | Значение | Минимальный размер |
bool | Логический тип | Не определен |
char | Символ | 8 битов |
wchar_t | Широкий символ | 16 битов |
char16_t | Символ Unicode | 16 битов |
char32_t | Символ Unicode | 32 бита |
short | Короткое целое число | 16 битов |
int | Целое число | 16 битов |
long | Длинное целое число | 32 бита |
long long | Длинное целое число | 64 бита |
float | Число с плавающей запятой одинарной точности | 6 значащих цифр |
double | Число с плавающей запятой двойной точности | 10 значащих цифр |
long double | Число с плавающей запятой повышенной точности | 10 значащих цифр |
Тип bool представляет только значения true (истина) и false (ложь).
Существует несколько символьных типов, большинство из которых предназначено для поддержки национальных наборов символов. Базовый символьный тип, char, гарантировано велик, чтобы содержать числовые значения, соответствующие символам базового набора символов машины. Таким образом, тип char имеет тот же размер, что и один байт на данной машине.
Остальные символьные типы, wchar_t, char16_t и char32_t, используются для расширенных наборов символов. Тип wchar_t будет достаточно большим, чтобы содержать любой символ в наибольшем расширенном наборе символов машины. Типы char16_t и char32_t предназначены для символов Unicode. (Unicode — это стандарт для представления символов, используемых, по существу, в любом языке.)
Остальные целочисленные типы представляют целочисленные значения разных размеров. Язык С++ гарантирует, что тип int будет по крайней мере не меньше типа short, а тип long long — не меньше типа long. Тип long long введен новым стандартом.
Машинный уровень представления встроенных типов
Компьютеры хранят данные как последовательность битов, каждый из которых содержит 0 или 1:
00011011011100010110010000111011 ...
Большинство компьютеров оперируют с памятью, разделенной на порции, размер которых в битах кратен степеням числа 2. Наименьшая порция адресуемой памяти называется байтом (byte). Основная единица хранения, обычно в несколько байтов, называется словом (word). В языке С++ байт содержит столько битов, сколько необходимо для содержания символа в базовом наборе символов машины. На большинстве компьютеров байт содержит 8 битов, а слово — 32 или 64 бита, т.е. 4 или 8 байтов.
У большинства компьютеров каждый байт памяти имеет номер, называемый адресом (address). На машине с 8-битовыми байтами и 32-битовыми словами слова в памяти можно было бы представить следующим образом:
FB2Library.Elements.Table.TableItem
Слева представлен адрес байта, а 8 битов его значения — справа.
При помощи адреса можно обратиться к любому из байтов, а также к набору из нескольких байтов, начинающемуся с этого адреса. В этом случае говорят о доступе к байту по адресу 736424 или о байте, хранящемуся по адресу 736426. Чтобы получить представление о значении в области памяти по данному адресу, следует знать тип хранимого в ней значения. Именно тип определяет количество используемых битов и то, как эти биты интерпретировать.
Если известно, что объект в области по адресу 736424 имеет тип float , и если тип float на этой машине хранится в 32 битах, то известно и то, что объект по этому адресу охватывает все слово. Значение этого числа зависит от того, как именно машина хранит числа с плавающей запятой. Но если объект в области по адресу 736424 имеет тип unsigned char , то на машине, использующей набор символов ISO-Latin-1, этот байт представляет точку с запятой.
Типы с плавающей точкой представляют значения с одиночной, двойной и расширенной точностью. Стандарт определяет минимальное количество значащих цифр. Большинство компиляторов обеспечивает большую точность, чем минимально определено стандартом. Как правило, тип float представляется одним словом (32 бита), тип double — двумя словами (64 бита), а тип long double — тремя или четырьмя словами (96 или 128 битов). Типы float и double обычно имеют примерно по 7 и 16 значащих цифр соответственно. Тип long double зачастую используется для адаптации чисел с плавающей запятой аппаратных средств специального назначения; его точность, вероятно, также зависит от конкретной реализации этих средств.
Знаковые и беззнаковые типы
За исключением типа bool и расширенных символьных типов целочисленные типы могут быть знаковыми (signed) или беззнаковыми (unsigned). Знаковый тип способен представлять отрицательные и положительные числа (включая нуль); а беззнаковый тип — только положительные числа и нуль.
Типы int, short, long и long long являются знаковыми. Соответствующий беззнаковый тип получают добавлением части unsigned к названию такого типа, например unsigned long. Тип unsigned int может быть сокращен до unsigned.
В отличие от других целочисленных типов, существуют три разновидности базового типа char: char, signed char и unsigned char. В частности, тип char отличается от типа signed char. На три символьных типа есть только два представления: знаковый и беззнаковый. Простой тип char использует одно из этих представлений. Какое именно, зависит от компилятора.
В беззнаковом типе все биты представляют значение. Например, 8-битовый тип unsigned char может содержать значения от 0 до 255 включительно.
Стандарт не определяет представление знаковых типов, но он указывает, что диапазон должен быть поровну разделен между положительными и отрицательными значениями. Следовательно, 8-битовый тип signed char гарантированно будет в состоянии содержать значения от -127 до 127; большинство современных машин использует представления, позволяющие содержать значения от -128 до 127.
Совет. Какой тип использовать
Подобно языку С, язык С++ был разработан так, чтобы по необходимости программа могла обращаться непосредственно к аппаратным средствам. Поэтому арифметические типы определены так, чтобы соответствовать особенностям различных аппаратных средств. В результате количество возможных арифметических типов в языке С++ огромно. Большинство программистов, желая избежать этих сложностей, ограничивают количество фактически используемых ими типов. Ниже приведено несколько эмпирических правил, способных помочь при выборе используемого типа.
• Используйте беззнаковый тип, когда точно знаете, что значения не могут быть отрицательными.
• Используйте тип int для целочисленной арифметики. Тип short обычно слишком мал, а тип long на практике зачастую имеет тот же размер, что и тип int . Если ваши значения больше, чем минимально гарантирует тип int , то используйте тип long long .
• Не используйте базовый тип char и тип bool в арифметических выражениях. Используйте их только для хранения символов и логических значений. Вычисления с использованием типа char особенно проблематичны, поскольку на одних машинах он знаковый, а на других беззнаковый. Если необходимо маленькое целое число, явно определите тип как signed char или unsigned char .
• Используйте тип double для вычислений с плавающей точкой. У типа float обычно недостаточно точности, а различие в затратах на вычисления с двойной и одиночной точностью незначительны. Фактически на некоторых машинах операции с двойной точностью осуществляются быстрее, чем с одинарной. Точность, предоставляемая типом long double , обычно чрезмерна и не нужна, а зачастую влечет значительное увеличение продолжительности выполнения.
Упражнения раздела 2.1.1
Упражнение 2.1. Каковы различия между типами int, long, long long и short? Между знаковыми и беззнаковыми типами? Между типами float и double?
Упражнение 2.2. Какие типы вы использовали бы для коэффициента, основной суммы и платежей при вычислении выплат по закладной? Объясните, почему вы выбрали каждый из типов?
2.1.2. Преобразование типов
Тип объекта определяет данные, которые он может содержать, и операции, которые с ним можно выполнять. Среди операций, поддерживаемых множеством типов, есть возможность преобразовать (convert) объект данного типа в другой, связанный тип.
Преобразование типов происходит автоматически, когда объект одного типа используется там, где ожидается объект другого типа. Более подробная информация о преобразованиях приведена в разделе 4.11, а пока имеет смысл понять, что происходит при присвоении значения одного типа объекту другого.
Когда значение одного арифметического типа присваивается другому
bool b = 42; // b содержит true
int i = b; // i содержит значение 1
i = 3.14; // i содержит значение 3
double pi = i; // pi содержит значение 3.0
unsigned char с = -1; // при 8-битовом char содержит значение 255
signed char c2 = 256; // при 8-битовом char значение c2 не определено
происходящее зависит от диапазона значении, поддерживаемых типом.
• Когда значение одного из не логических арифметических типов присваивается объекту типа bool, результат будет false, если значением является 0, а в противном случае — true.
• Когда значение типа bool присваивается одному из других арифметических типов, будет получено значение 1, если логическим значением было true, и 0, если это было false.
• Когда значение с плавающей точкой присваивается объекту целочисленного типа, оно усекается до части перед десятичной точкой.
• Когда целочисленное (интегральное) значение присваивается объекту типа с плавающей точкой, дробная часть равна нулю. Если у целого числа больше битов, чем может вместить объект с плавающей точкой, то точность может быть потеряна.
• Если объекту беззнакового типа присваивается значение не из его диапазона, результатом будет остаток от деления по модулю значения, которые способен содержать тип назначения. Например, 8-битовый тип unsigned char способен содержать значения от 0 до 255 включительно. Если присвоить ему значение вне этого диапазона, то компилятор присвоит ему остаток от деления по модулю 256. Поэтому в результате присвоения значения -1 переменной 8-битового типа unsigned char будет получено значение 255.
• Если объекту знакового типа присваивается значение не из его диапазона, результат оказывается не определен. В результате программа может сработать нормально, а может и отказать или задействовать неверное значение.
Совет. Избегайте неопределенного и машинно-зависимого поведения
Результатом неопределенного поведения являются такие ошибки, которые компилятор не обязан (а иногда и не в состоянии) обнаруживать. Даже если код компилируется, то программа с неопределенным выражением все равно ошибочна.
К сожалению, программы, характеризующиеся неопределенным поведением на некоторых компиляторах и при некоторых обстоятельствах, могут работать вполне нормально, не проявляя проблему. Но нет никаких гарантий, что та же программа, откомпилированная на другом компиляторе или даже на следующей версии данного компилятора, продолжит работать правильно. Нет даже гарантий того, что, нормально работая с одним набором данных, она будет нормально работать с другим.
Аналогично в программах нельзя полагаться на машинно-зависимое поведение. Не стоит, например, надеяться на то, что переменная типа int имеет фиксированный, заранее известный размер. Такие программы называют непереносимыми (nonportable). При переносе такой программы на другую машину любой полагающийся на машинно-зависимое поведение код, вероятней всего, сработает неправильно, поэтому его придется искать и исправлять. Поиск подобных проблем в ранее нормально работавшей программе, мягко говоря, не самая приятная работа.
Компилятор применяет эти преобразования типов при использовании значений одного арифметического типа там, где ожидается значение другого арифметического типа. Например, при использовании значения, отличного от логического в условии, арифметическое значение преобразуется в тип bool таким же образом, как при присвоении арифметического значения переменной типа bool:
int i = 42;
if (i) // условие рассматривается как истинное
i = 0;
При значении 0 условие будет ложным, а при всех остальных (отличных от нуля) — истинным.
К тому же при использовании значения типа bool в арифметическом выражении оно всегда преобразуется в 0 или 1. В результате применение логического значения в арифметическом выражении является неправильным.
#magnify.png Выражения, задействующие беззнаковые типы
Хотя мы сами вряд ли преднамеренно присвоим отрицательное значение объекту беззнакового типа, мы можем (причем слишком легко) написать код, который сделает это неявно. Например, если использовать значения типа unsigned и int в арифметическом выражении, значения типа int обычно преобразуются в тип unsigned. Преобразование значения типа int в unsigned выполняется таким же способом, как и при присвоении:
unsigned u = 10;
int i = -42;
std::cout << i + i << std::endl; // выводит -84
std::cout << u + i << std::endl; // при 32-битовом int,
// выводит 4294967264
Во втором выражении, прежде чем будет осуществлено сложение, значение -42 типа int преобразуется в значение типа unsigned. Преобразование отрицательного числа в тип unsigned происходит точно так же, как и при попытке присвоить это отрицательное значение объекту типа unsigned. Произойдет "обращение значения" (wrap around), как было описано выше.
При вычитании значения из беззнакового объекта, независимо от того, один или оба операнда являются беззнаковыми, следует быть уверенным том, что результат не окажется отрицательным:
unsigned u1 = 42, u2 = 10;
std::cout << u1 - u2 << std::endl; // ok: результат 32
std::cout << u2 - u1 << std::endl; // ok: но с обращением значения
Тот факт, что беззнаковый объект не может быть меньше нуля, влияет на способы написания циклов. Например, в упражнениях раздела 1.4.1 (стр. 39) следовало написать цикл, который использовал оператор декремента для вывода чисел от 10 до 0. Написанный вами цикл, вероятно, выглядел примерно так:
for (int i = 10; i >= 0; --i)
std::cout << i << std::endl;
Казалось бы, этот цикл можно переписать, используя тип unsigned. В конце концов, мы не планируем выводить отрицательные числа. Однако это простое изменение типа приведет к тому, что цикл никогда не закончится:
// ОШИБКА: u никогда не сможет стать меньше 0; условие
// навсегда останется истинным
for (unsigned u = 10; u >= 0; --u)
std::cout << u << std::endl;
Рассмотрим, что будет, когда u станет равно 0. На этой итерации отображается значение 0, а затем выполняется выражение цикла for. Это выражение, --u, вычитает 1 из u. Результат, -1, недопустим для беззнаковой переменной. Как и любое другое значение, не попадающее в диапазон допустимых, это будет преобразовано в беззнаковое значение. При 32-разрядном типе int результат выражения --u при u равном 0 составит 4294967295.
Исправить этот код можно, заменив цикл for циклом while, поскольку последний осуществляет декремент прежде (а не после) отображения значения:
unsigned u = 11; // начать цикл с элемента на один больше
// первого, подлежащего отображению
while (u > 0) {
--u; // сначала декремент, чтобы последняя итерация отобразила 0
std::cout << u << std::endl;
}
Цикл начинается с декремента значения управляющей переменной цикла. В начале последней итерации переменная u будет иметь значение 1, а после декремента мы отобразим значение 0. При последующей проверке условия цикла while значением переменной u будет 0, и цикл завершится. Поскольку декремент осуществляется сначала, переменную u следует инициализировать значением на единицу больше первого подлежащего отображению значения. Следовательно, чтобы первым отображаемым значением было 10, переменную u инициализируем значением 11.
Внимание! Не смешивайте знаковые и беззнаковые типы
Выражения, в которых смешаны знаковые и беззнаковые типы, могут приводить к удивительным результатам, когда знаковое значение оказывается негативным. Важно не забывать, что знаковые значения автоматически преобразовываются в беззнаковые. Например, в таком выражении, как a * b , если а содержит значение -1 , a b значение 1 и обе переменные имеют тип int , ожидается результат -1 . Но если переменная а имеет тип int , а переменная b — тип unsigned , то значение этого выражения будет зависеть от количества битов, занимаемых типом int на данной машине. На нашей машине результатом этого выражения оказалось 4294967295 .
Упражнения раздела 2.1.2
Упражнение 2.3. Каков будет вывод следующего кода?
unsigned u = 10, u2 = 42;
std::cout << u2 - u << std::endl;
std::cout << u - u2 << std::endl;
int i = 10, i2 = 42;
std::cout << i2 - i << std::endl;
std::cout << i - i2 << std::endl;
std::cout << i - u << std::endl;
std::cout << u - i << std::endl;
Упражнение 2.4. Напишите программу для проверки правильности ответов. При неправильных ответах изучите этот раздел еще раз.
2.1.3. Литералы
Такое значение, как 42, в коде программы называется литералом (literal), поскольку его значение самоочевидно. У каждого литерала есть тип, определяемый его формой и значением.
Целочисленные литералы и литералы с плавающей запятой
Целочисленный литерал может быть в десятичной, восьмеричной или шестнадцатеричной форме. Целочисленные литералы, начинающиеся с нуля (0), интерпретируются как восьмеричные, а начинающиеся с 0x или 0X — как шестнадцатеричные. Например, значение 20 можно записать любым из трех следующих способов.
20 // десятичная форма
024 // восьмеричная форма
0x14 // шестнадцатеричная форма
Тип целочисленного литерала зависит от его значения и формы. По умолчанию десятичные литералы считаются знаковыми, а восьмеричные и шестнадцатеричные литералы могут быть знаковыми или беззнаковыми. Для десятичного литерала принимается наименьший тип, int, long, или long long, подходящий для его значения (т.е. первый подходящий в этом списке). Для восьмеричных и шестнадцатеричных литералов принимается наименьший тип, int, unsigned int, long, unsigned long, long long или unsigned long long, подходящий для значения литерала. Не следует использовать литерал, значение которого слишком велико для наибольшего соответствующего типа. Нет литералов типа short. Как можно заметить в табл. 2.2, значения по умолчанию можно переопределить при помощи суффикса.
Хотя целочисленные литералы могут иметь знаковый тип, с технической точки зрения значение десятичного литерала никогда не бывает отрицательным числом. Если написать нечто, выглядящее как отрицательный десятичный литерал, например -42, то знак "минус" не будет частью литерала. Знак "минус" — это оператор, который инвертирует знак своего операнда (литерала).
Литералы с плавающей запятой включают либо десятичную точку, либо экспоненту, определенную при помощи экспоненциального представления. Экспонента в экспоненциальном представлении обозначается символом E или е:
3.14159 3.14159Е0 0. 0e0 .001
По умолчанию литералы с плавающей запятой имеют тип double. Используя представленные в табл. 2.2 суффиксы, тип умолчанию можно переопределить.
Символьные и строковые литералы
Символ, заключенный в одинарные кавычки, является литералом типа char. Несколько символов, заключенных в парные кавычки, являются строковым литералом:
'a' // символьный литерал
"Hello World!" // строковый литерал
Типом строкового литерала является массив константных символов. Этот тип обсуждается в разделе 3.5.4. К каждому строковому литералу компилятор добавляет нулевой символ (null character) ('\0'). Таким образом, реальная величина строкового литерала на единицу больше его видимого размера. Например, литерал 'A' представляет один символ А, тогда как строковый литерал "А" представляет массив из двух символов, символа А и нулевого символа.
Два строковых литерала, разделенных пробелами, табуляцией или символом новой строки, конкатенируются в единый литерал. Такую форму литерала используют, если необходимо написать слишком длинный текст, который неудобно располагать в одной строке.
// многострочный литерал
std::cout << "a really, really long string literal "
"that spans two lines" << std::endl;
Управляющие последовательности
У некоторых символов, таких как возврат на один символ или управляющий символ, нет видимого изображения. Такие символы называют непечатаемыми (nonprintable character). Другие символы (одиночные и парные кавычки, вопросительный знак и наклонная черта влево) имеют в языке специальное назначение. В программах нельзя использовать ни один из этих символов непосредственно. Для их представления как символов используется управляющая последовательность (escape sequence), начинающаяся с символа наклонной черты влево.
В языке С++ определены следующие управляющие последовательности.
Новая строка (newline) | \n | Горизонтальная табуляция (horizontal tab) | \t | Оповещение, звонок (alert) | \a |
Вертикальная табуляция (vertical tab) | \v | Возврат на один символ (backspace) | \b | Двойная кавычка (double quote) | \" |
Наклонная черта влево (backslash) | \\ | Вопросительный знак (question mark) | \? | Одинарная кавычка (single quote) | \' |
Возврат каретки (carriage return) | \r | Прогон страницы (formfeed) | \f |
Управляющую последовательность используют как единый символ:
std::cout << '\n'; // отобразить новую строку
std::cout << "\tHi!\n"; // отобразить табуляцию,
// текст "Hi!" и новую строка
Можно также написать обобщенную управляющую последовательность, где за \x следует одна или несколько шестнадцатеричных цифр или за \ следует одна, две или три восьмеричные цифры. Так можно отобразить символ по его числовому значению. Вот несколько примеров (подразумевается использование набора символов Latin-1):
\7 (оповещение) \12 (новая строка) \40 (пробел)
\0 (нулевой символ) \115 (символ 'M') \x4d (символ 'M')
Как и управляющие последовательности, определенные языком, такой синтаксис можно использовать вместо любого другого символа:
std::cout << "Hi \x4dO\115!\n"; // выводит Hi MOM! и новую строку
std::cout << '\115' << '\n'; // выводит M и новую строку
Обратите внимание: если символ \ сопровождается более чем тремя восьмеричными цифрами, то ассоциируются с ним только первые три. Например, литерал "\1234" представляет два символа: символ, представленный восьмеричным значением 123, и символ 4. Форма \x, напротив, использует все последующие шестнадцатеричные цифры; литерал "\x1234" представляет один 16-разрядный символ, состоящий из битов, соответствующих этим четырем шестнадцатеричным цифрам. Поскольку большинство машин использует 8-битовые символы, подобные значения вряд ли будут полезны. Обычно шестнадцатеричные символы с более чем 8 битами используются для расширенных наборов символов с применением одного из префиксов, приведенных в табл. 2.2.
Определение типа литерала
При помощи суффикса или префикса, представленного в табл. 2.2, можно переопределить заданный по умолчанию тип целого числа, числа с плавающей запятой или символьного литерала.
L'a' // литерал типа wchar_t (широкий символ)
u8"hi!" // строковый литерал utf-8 (8-битовая кодировка Unicode)
42ULL // целочисленный беззнаковый литерал, тип unsigned long long
1E-3F // литерал с плавающей точкой и одинарной точностью, тип float
3.14159L // литерал с плавающей точкой и расширенной точностью,
// тип long double
При обозначении литерала как имеющего тип long используйте букву L в верхнем регистре; строчная буква l слишком похожа на цифру 1.
Таблица 2.2. Определение типа литерала
Символьные и строковые литералы | |||
Префикс | Значение | Тип | |
U | Символ Unicode 16 | char16_t | |
U | Символ Unicode 32 | char32_t | |
L | Широкий символ | wchar_t | |
U8 | utf-8 (только строковые литералы) | char | |
Целочисленные литералы | Литералы с плавающей точкой | ||
Суффикс | Минимальный тип | Суффикс | Тип |
u или U | unsigned | f или F | float |
l или L | long | l или L | long double |
Ll или LL | long long |
Можно непосредственно определить знак и размер целочисленного литерала. Если суффикс содержит символ U, то у литерала беззнаковый тип. Таким образом, у десятичного, восьмеричного или шестнадцатеричного литерала с суффиксом U будет наименьший тип unsigned int, unsigned long или unsigned long long, в соответствии со значением литерала. Если суффикс будет содержать символ L, то типом литерала будет по крайней мере long; если суффикс будет содержать символы LL, то типом литерала будет long long или unsigned long long.
Можно объединить символ U с символом L или символами LL. Литерал с суффиксом UL, например, задаст тип unsigned long или unsigned long long, в зависимости от того, помещается ли его значение в тип unsigned long.
Логические литералы и литеральные указатели
Слова true и false — это логические литералы (литералы типа bool)
bool test = false;
Слово nullptr является литеральным указателем. Более подробная информация об указателях и литерале nullptr приведена в разделе 2.3.2.
Упражнения раздела 2.1.3
Упражнение 2.5. Определите тип каждого из следующих литералов. Объясните различия между ними:
(a) 'a', L'a', "a", L"a"
(b) 10, 10u, 10L, 10uL, 012, 0xC
(c) 3.14, 3.14f, 3.14L
(d) 10, 10u, 10., 10e-2
Упражнение 2.6. Имеются ли различия между следующими определениями:
int month = 9, day = 7;
int month = 09, day = 07;
Упражнение 2.7. Какие значения представляют эти литералы? Какой тип имеет каждый из них?
(a) "Who goes with F\145rgus?\012"
(b) 3.14e1L (c) 1024f (d) 3.14L
Упражнение 2.8. Напишите программу, использующую управляющие последовательности для вывода значения 2M, сопровождаемого новой строкой. Модифицируйте программу так, чтобы вывести 2, затем табуляцию, потом M и наконец символ новой строки.
2.2. Переменные
Переменная (variable) — это именованное хранилище, которым могут манипулировать программы. У каждой переменной в языке С++ есть тип. Тип определяет размер и расположение переменной в памяти, диапазон значений, которые могут храниться в ней, и набор применимых к переменной операций. Программисты С++ используют термины "переменная" и "объект" как синонимы.
2.2.1. Определения переменных
Простое определение переменной состоит из спецификатора типа (type specifier), сопровождаемого списком из одного или нескольких имен переменных, отделенных запятыми, и завершающей точки с запятой. Тип каждого имени в списке задан спецификатором типа. Определение может (не обязательно) предоставить исходное значение для одного или нескольких определяемых имен:
int sum = 0, value, // sum, value и units_sold имеют тип int
units_sold = 0; // sum и units_sold инициализированы значением 0
Sales_item item; // item имеет тип Sales_item (см. p. 1.5.1)
// string — библиотечный тип, представляющий последовательность
// символов переменной длины
std::string book("0-201-78345-X"); // book инициализирована строковым
// литералом
В определении переменной book использован библиотечный тип std::string. Подобно классу iostream (см. раздел 1.2), класс string определен в пространстве имен std. Более подробная информация о классе string приведена в главе 3, а пока достаточно знать то, что тип string представляет последовательность символов переменной длины. Библиотечный тип string предоставляет несколько способов инициализации строковых объектов. Один из них — копирование строкового литерала (см. раздел 2.1.3). Таким образом, переменная book инициализируется символами 0-201-78345-X.
Терминология. Что такое объект?
Программисты языка С++ используют термин объект (object) часто, и не всегда по делу. В самом общем определении объект — это область памяти, способная содержать данный и обладающая типом.
Одни программисты используют термин объект лишь для переменных и экземпляров классов. Другие используют его, чтобы различать именованные и неименованные объекты, причем для именованных объектов используют термин переменная (variable). Третьи различают объекты и значения, используя термин объект для тех данных, которые могут быть изменены программой, и термин значение (value) — для тех данных, которые предназначены только для чтения.
В этой книге используется наиболее общий смысл термина объект , т.е. область памяти, для которой указан тип. Здесь под объектом подразумеваются практически все используемые в программе данные, независимо от того, имеют ли они встроенный тип или тип класса, являются ли они именованными или нет, предназначены только для чтения или допускают изменение.
Инициализаторы
Инициализация (initialization) присваивает объекту определенное значение в момент его создания. Используемые для инициализации переменных значения могут быть насколько угодно сложными выражениями. Когда определяется несколько переменных, имена всех объектов следуют непосредственно друг за другом. Таким образом, вполне возможно инициализировать переменную значением одной из переменных, определенных ранее в том же определении.
// ok: переменная price определяется и инициализируется прежде,
// чем она будет использована для инициализации переменной discount
double price = 109.99, discount = price * 0.16;
// ok: Вызов функции applyDiscount() и использование ее возвращаемого
// значения для инициализации переменной salePrice
salePrice = applyDiscount(price, discount);
Инициализация в С++ — на удивление сложная тема, и мы еще не раз вернемся к ней. Многих программистов вводит в заблуждение использование символа = при инициализации переменной. Они полагают, что инициализация — это такая форма присвоения, но в С++ инициализация и присвоение — совершенно разные операции. Эта концепция особенно важна, поскольку во многих языках это различие несущественно и может быть проигнорировано. Тем не менее даже в языке С++ это различие зачастую не имеет значения. Однако данная концепция крайне важна, и мы будем повторять это еще не раз.
Инициализация — это не присвоение. Инициализация переменной происходит при ее создании. Присвоение удаляет текущее значение объекта и заменяет его новым.
Списочная инициализация
Тема инициализации настолько сложна потому, что язык поддерживает ее в нескольких разных формах. Например, для определения переменной units_sold типа int и ее инициализации значением 0 можно использовать любой из следующих четырех способов:
int units_sold = 0;
int units_sold = {0};
int units_sold{0};
int units_sold(0);
Использование фигурных скобок для инициализации было введено новым стандартом. Ранее эта форма инициализации допускалась лишь в некоторых случаях. По причинам, описанным в разделе 3.3.1, эта форма инициализации известна как списочная инициализация (list initialization). Списки инициализаторов в скобках можно теперь использовать всегда, когда инициализируется объект, и в некоторых случаях, когда объекту присваивается новое значение.
При использовании с переменными встроенного типа эта форма инициализации обладает важным преимуществом: компилятор не позволит инициализировать переменные встроенного типа, если инициализатор может привести к потере информации:
long double ld = 3.1415926536;
int a{ld}, b = {ld}; // ошибка: преобразование с потерей
int с(ld), d = ld; // ok: но значение будет усечено
Компилятор откажет в инициализации переменных а и b, поскольку использование значения типа long double для инициализации переменной типа int может привести к потере данных. Как минимум, дробная часть значения переменной ld будет усечена. Кроме того, целочисленная часть значения переменной ld может быть слишком большой, чтобы поместиться в переменную типа int.
То, что здесь представлено, может показаться тривиальным, в конце концов, вряд ли кто инициализирует переменную типа int значением типа long double непосредственно. Однако, как представлено в главе 16, такая инициализация может произойти непреднамеренно. Более подробная информация об этих формах инициализации приведена в разделах 3.2.1 и 3.3.1.
Инициализация по умолчанию
При определении переменной без инициализатора происходит ее инициализация по умолчанию (default initialization). Таким переменным присваивается значение по умолчанию (default value). Это значение зависит от типа переменной и может также зависеть от того, где определяется переменная.
Значение объекта встроенного типа, не инициализированного явно, зависит от того, где именно он определяется. Переменные, определенные вне тела функции, инициализируются значением 0. За одним рассматриваемым вскоре исключением, определенные в функции переменные встроенного типа остаются неинициализированными (uninitialized). Значение неинициализированной переменной встроенного типа неопределенно (см. раздел 2.1.2). Попытка копирования или получения доступа к значению неинициализированной переменной является ошибкой.
Инициализацию объекта типа класса контролирует сам класс. В частности, класс позволяет определить, могут ли быть созданы его объекты без инициализатора. Если это возможно, класс определяет значение, которое будет иметь его объект в таком случае.
Большинство классов позволяет определять объекты без явных инициализаторов. Такие классы самостоятельно предоставляют соответствующее значение по умолчанию. Например, новый объект библиотечного класса string без инициализатора является пустой строкой.
std::string empty; // неявно инициализируется пустой строкой
Sales_item item; // объект Sales_item инициализируется
// значением по умолчанию
Однако некоторые классы требуют, чтобы каждый объект был инициализирован явно. При попытке создать объект такого класса без инициализатора компилятор пожалуется на это.
Значение неинициализированных объектов встроенного типа, определенных в теле функции, неопределенно. Значение не инициализируемых явно объектов типа класса определяется классом.
Упражнения раздела 2.2.1
Упражнение 2.9. Объясните следующие определения. Если среди них есть некорректные, объясните, что не так и как это исправить.
(а) std::cin >> int input_value; (b) int i = { 3.14 };
(с) double salary = wage = 9999.99; (d) int i = 3.14;
Упражнение 2.10. Каковы исходные значения, если таковые вообще имеются, каждой из следующих переменных?
std::string global str;
int global_int;
int main() {
int local_int;
std::string local_str;
}
2.2.2. Объявления и определения переменных
Для обеспечения возможности разделить программу на несколько логических частей язык С++ предоставляет технологию, известную как раздельная компиляция (separate compilation). Раздельная компиляция позволяет составлять программу из нескольких файлов, каждый из которых может быть откомпилирован независимо.
При разделении программы на несколько файлов необходим способ совместного использования кода этих файлов. Например, код, определенный в одном файле, возможно, должен использовать переменную, определенную в другом файле. В качестве конкретного примера рассмотрим объекты std::cout и std::cin. Классы этих объектов определены где-то в стандартной библиотеке, но все же наши программы могут использовать их.
Внимание! Неинициализированные переменные — причина проблем во время выполнения
Значение неинициализированной переменной неопределенно. Попытка использования значения неинициализированной переменной является ошибкой, которую зачастую трудно обнаружить. Кроме того, компилятор не обязан обнаруживать такие ошибки, хотя большинство из них предупреждает, по крайней мере, о некоторых случаях использования неинициализированных переменных.
Что же произойдет при использовании неинициализированной переменной с неопределенным значением? Иногда (если повезет) программа отказывает сразу, при попытке доступа к объекту. Обнаружив место, где происходит отказ, как правило, довольно просто выяснить, что его причиной является неправильно инициализированная переменная. Но иногда программа срабатывает, хотя результат получается ошибочным. Возможен даже худший вариант, когда на одной машине результаты получаются правильными, а на другой происходит сбой. Кроме того, добавление кода во вполне работоспособную программу в неподходящем месте тоже может привести к внезапному возникновению проблем.
Мы рекомендуем инициализировать каждый объект встроенного типа. Это не всегда необходимо, но проще и безопасней предоставить инициализатор, чем выяснять, можно ли в данном конкретном случае безопасно опустить его.
Для поддержки раздельной компиляции язык С++ различает объявления и определения. Объявление (declaration) делает имя известным программе. Файл, который должен использовать имя, определенное в другом месте, включает объявление для этого имени. Определение (definition) создает соответствующую сущность.
Объявление переменной определяет ее тип и имя. Определение переменной — это ее объявление. Кроме задания имени и типа, определение резервирует также место для ее хранения и может снабдить переменную исходным значением.
Чтобы получить объявление, не являющееся также определением, добавляется ключевое слово extern и можно не предоставлять явный инициализатор.
extern int i; // объявить, но не определить переменную i
int j; // объявить и определить переменную j
Любое объявление, которое включает явный инициализатор, является определением. Для переменной, определенной как extern (внешняя), можно предоставить инициализатор, но это отменит ее определение как extern. Объявление внешней переменной с инициализатором является ее определением:
extern double pi = 3.1416; // определение
Предоставление инициализатора внешней переменной в функции является ошибкой.
Объявлены переменные могут быть много раз, но определены только однажды.
На настоящий момент различие между объявлением и определением может показаться неочевидным, но фактически оно очень важно. Использование переменной в нескольких файлах требует объявлений, отдельных от определения. Чтобы использовать ту же переменную в нескольких файлах, ее следует определить в одном, и только одном файле. В других файлах, где используется та же переменная, ее следует объявить, но не определять.
Более подробная информация о том, как язык С++ поддерживает раздельную компиляцию, приведена в разделах 2.6.3 и 6.1.3.
Упражнения раздела 2.2.2
Упражнение 2.11. Объясните, приведены ли ниже объявления или определения.
(a) extern int ix = 1024;
(b) int iy;
(c) extern int iz;
Ключевая концепция. Статическая типизация
Язык С++ обладает строгим статическим контролем типов (statically typed) данных. Это значит, что проверка соответствия значений заявленным для них типам данных осуществляется во время компиляции. Сам процесс проверки называют контролем соответствия типов (type-checking), или типизацией (typing).
Как уже упоминалось, тип ограничивает операции, которые можно выполнять с объектом. В языке С++ компилятор проверяет, поддерживает ли используемый тип операции, которые с ним выполняют. Если обнаруживается попытка сделать нечто, не поддерживаемое данным типом, компилятор выдает сообщение об ошибке и не создает исполняемый файл.
По мере усложнения рассматриваемых программ будет со всей очевидностью продемонстрировано, что строгий контроль соответствия типов способен помочь при поиске ошибок в исходном коде. Однако последствием статической проверки является то, что тип каждой используемой сущности должен быть известен компилятору. Следовательно, тип переменной необходимо объявить прежде, чем эту переменную можно будет использовать.
2.2.3. Идентификаторы
Идентификаторы (identifier) (или имена) в языке С++ могут состоять из символов, цифр и символов подчеркивания. Язык не налагает ограничений на длину имен. Идентификаторы должны начинаться с букв или символа подчеркивания. Символы в верхнем и нижнем регистрах различаются, т.е. идентификаторы языка С++ чувствительны к регистру.
// определено четыре разных переменных типа int
int somename, someName, SomeName, SOMENAME;
Язык резервирует набор имен, перечисленных в табл. 2.3 и 2.4, для собственных нужд. Эти имена не могут использоваться как идентификаторы.
Таблица 2.3. Ключевые слова языка С++
alignas | continue | friend | register | true |
alignof | decltype | goto | reinterpret_cast | try |
asm | default | if | return | typedef |
auto | delete | inline | short | typeid |
bool | do | int | signed | typename |
break | double | long | sizeof | union |
case | dynamic_cast | mutable | static | unsigned |
catch | else | namespace | static_assert | using |
char | enum | new | static_cast | virtual |
char16_t | explicit | noexcept | struct | void |
char32_t | export | nullptr | switch | volatile |
class | extern | operator | template | wchar_t |
const | false | private | this | while |
constexpr | float | protected | thread_local | |
const_cast | for | public | throw |
Таблица 2.4. Альтернативные имена операторов языка С++
and | bitand | compl | not_eq | or_eq | xor_eq |
and_eq | bitor | not | or | xor |
Кроме ключевых слов, стандарт резервирует также набор идентификаторов для использования в библиотеке, поэтому пользовательские идентификаторы не могут содержать два последовательных символа подчеркивания, а также начинаться с символа подчеркивания, непосредственно за которым следует прописная буква. Кроме того, идентификаторы, определенные вне функций, не могут начинаться с символа подчеркивания.
Соглашения об именах переменных
Существует множество общепринятых соглашений для именования переменных. Применение подобных соглашений может существенно улучшать удобочитаемость кода.
• Идентификатор должен быть осмысленным.
• Имена переменных обычно состоят из строчных символов. Например, index, а не Index или INDEX.
• Имена классов обычно начинаются с прописной буквы, например Sales_item.
• Несколько слов в идентификаторе разделяют либо символом подчеркивания, либо прописными буквами в первых символах каждого слова. Например: student_loan или studentLoan, но не studentloan.
Самым важным аспектом соглашения об именовании является его неукоснительное соблюдение.
Упражнения раздела 2.2.3
Упражнение 2.12. Какие из приведенных ниже имен недопустимы (если таковые есть)?
(a) int double = 3.14; (b) int _;
(с) int catch-22; (d) int 1_or_2 = 1;
(e) double Double = 3.14;
2.2.4. Область видимости имен
В любом месте программы каждое используемое имя относится к вполне определенной сущности — переменной, функции, типу и т.д. Однако имя может быть использовано многократно для обращения к различным сущностям в разных точках программы.
Область видимости (scope) — это часть программы, в которой у имени есть конкретное значение. Как правило, области видимости в языке С++ разграничиваются фигурными скобками.
В разных областях видимости то же имя может относиться к разным сущностям. Имена видимы от момента их объявления и до конца области видимости, в которой они объявлены.
В качестве примера рассмотрим программу из раздела 1.4.2:
#include
int main() {
int sum = 0;
// сложить числа от 1 до 10 включительно
for (int val = 1; val <= 10; ++val)
sum += val; // эквивалентно sum = sum + val
std::cout << "Sum of 1 to 10 inclusive is "
<< sum << std::endl;
return 0;
}
Эта программа определяет три имени — main, sum и val, а также использует имя пространства имен std, наряду с двумя именами из этого пространства имен — cout и endl.
Имя main определено вне фигурных скобок. Оно, как и большинство имен, определенных вне функции, имеет глобальную область видимости (global scope). Будучи объявлены, имена в глобальной области видимости доступны в программе повсюду. Имя sum определено в пределах блока, которым является тело функции main(). Оно доступно от момента объявления и далее в остальной части функции main(), но не за ее пределами. Переменная sum имеет область видимости блока (block scope). Имя val определяется в пределах оператора for. Оно применимо только в этом операторе, но не в другом месте функции main().
Совет. Определяйте переменные при первом использовании
Объект имеет смысл определять поближе к месту его первого использования. Это улучшает удобочитаемость и облегчает поиск определения переменной. Однако важней всего то, что когда переменная определяется ближе к месту ее первого использования, зачастую проще присвоить ей подходящее исходное значение.
Вложенные области видимости
Области видимости могут содержать другие области видимости. Содержащаяся (или вложенная) область видимости называется внутренней областью видимости (inner scope), а содержащая ее области видимости — внешней областью видимости (outer scope).
Как только имя объявлено в области видимости, оно становится доступно во вложенных в нее областях видимости. Имена, объявленные во внешней области видимости, могут быть также переопределены во внутренней области видимости:
#include
// Программа предназначена исключительно для демонстрации.
// Использование в функции глобальной переменной, а также определение
// одноименной локальной переменной - это очень плохой стиль
// программирования
int reused = 42; // reused имеет глобальную область видимости
int main()
{
int unique = 0; // unique имеет область видимости блока
// вывод #1; используется глобальная reused; выводит 42 0
std::cout << reused << " " << unique << std::endl;
int reused = 0; // новый локальный объект по имени reused скрывает
// глобальный reused
// вывод #2: используется локальная reused; выводит 0 0
std::cout << reused << " " << unique << std::endl;
// вывод #3: явное обращение к глобальной reused; выводит 42 0
std::cout << ::reused << " " << unique << std::endl;
return 0;
}
Вывод #1 осуществляется перед определением локальной переменной reused. Поэтому данный оператор вывода использует имя reused, определенное в глобальной области видимости. Этот оператор выводит 42 0. Вывод #2 происходит после определения локальной переменной reused. Теперь локальная переменная reused находится в области видимости (in scope). Таким образом, второй оператор вывода использует локальный объект reused, а не глобальный и выводит 0 0. Вывод #3 использует оператор области видимости (см. раздел 1.2) для переопределения стандартных правил областей видимости. У глобальной области видимости нет имени. Следовательно, когда у оператора области видимости пусто слева, это обращение к указанному справа имени в глобальной области видимости. Таким образом, это выражение использует глобальный объект reused и выводит 42 0.
Как правило, определение локальных переменных, имена которых совпадают с именами глобальных переменных, является крайне неудачным решением.
Упражнения раздела 2.2.4
Упражнение 2.13. Каково значение переменной j в следующей программе?
int i = 42;
int main() {
int i = 100;
int j = i;
}
Упражнение 2.14. Допустим ли следующий код? Если да, то какие значения он отобразит на экране?
int i = 100, sum = 0;
for (int i = 0; i != 10; ++i)
sum += i;
std::cout << i << " " << sum << std::endl;
2.3. Составные типы
Составной тип (compound type) — это тип, определенный в терминах другого типа. У языка С++ есть несколько составных типов, два из которых, ссылки и указатели, мы рассмотрим в этой главе.
У рассмотренных на настоящий момент объявлений не было ничего, кроме имен переменных. Такие переменные имели простейший, базовый тип объявления. Более сложные операторы объявления позволяют определять переменные с составными типами, которые состоят из объявлений базового типа.
2.3.1. Ссылки
Ссылка (reference) является альтернативным именем объекта. Ссылочный тип "ссылается на" другой тип. В определении ссылочного типа используется оператор объявления в форме &d, где d — объявляемое имя:
int ival = 1024;
int &refVal = ival; // refVal ссылается на другое имя, ival
int &refVal2; // ошибка: ссылку следует инициализировать
Обычно при инициализации переменной значение инициализатора копируется в создаваемый объект. При определении ссылки вместо копирования значения инициализатора происходит связывание (bind) ссылки с ее инициализатором. После инициализации ссылка остается связанной с исходным объектом. Нет никакого способа изменить привязку ссылки так, чтобы она ссылалась на другой объект, поэтому ссылки следует инициализировать.
Новый стандарт ввел новый вид ссылки — ссылка r-значения (r-value reference), которую мы рассмотрим в разделе 13.6.1. Эти ссылки предназначены прежде всего для использования в классах. С технической точки зрения, когда мы используем термин ссылка (reference), мы подразумеваем ссылку l-значения (l-value reference).
#magnify.png Ссылка — это псевдоним
После того как ссылка определена, все операции с ней фактически осуществляются с объектом, с которым связана ссылка.
refVal = 2; // присваивает значение 2 объекту, на который ссылается
// ссылка refVal, т.е. ival
int ii = refVal; // то же, что и ii = ival
Ссылка — это не объект, а только другое имя уже существующего объекта.
При присвоении ссылки присваивается объект, с которым она связана. При доступе к значению ссылки фактически происходит обращение к значению объекта, с которым связана ссылка. Точно так же, когда ссылка используется как инициализатор, в действительности для этого используется объект, с которым связана ссылка.
// ok: ссылка refVal3 связывается с объектом, с которым связана
// ссылка refVal, т.е. с ival
int &refVal3 = refVal;
// инициализирует i значением объекта, с которым связана ссылка refVal
int i = refVal; // ok: инициализирует i значением ival
Поскольку ссылки не объекты, нельзя определить ссылку на ссылку.
Определение ссылок
В одном определении можно определить несколько ссылок. Каждому являющемуся ссылкой идентификатору должен предшествовать символ &.
int i = 1024, i2 = 2048; // i и i2 — переменные типа int
int &r = i, r2 = i2; // r — ссылка, связанная с переменной i;
// r2 — переменная типа int
int i3 = 1024, &ri = i3; // i3 — переменная типа int;
// ri — ссылка, связанная с переменной i3
int &r3 = i3, &r4 = i2; // r3 и r4 — ссылки
За двумя исключениями, рассматриваемыми в разделах 2.4.1 и 15.2.3, типы ссылки и объекта, на который она ссылается, должны совпадать точно. Кроме того, по причинам, рассматриваемым в разделе 2.4.1, ссылка может быть связана только с объектом, но не с литералом или результатом более общего выражения:
int &refVal4 = 10; // ошибка: инициализатор должен быть объектом
double dval = 3.14;
int &refVal5 = dval; // ошибка: инициализатор должен быть объектом
// типа int
Упражнения раздела 2.3.1
Упражнение 2.15. Какие из следующих определений недопустимы (если таковые есть)? Почему?
(a) int ival = 1.01; (b) int &rval1 = 1.01;
(с) int &rval2 = ival; (d) int &rval3;
Упражнение 2.16. Какие из следующих присвоений недопустимы (если таковые есть)? Если они допустимы, объясните, что они делают.
int i = 0, &r1 = i; double d = 0, &r2 = d;
(a) r2 = 3.14159; (b) r2 = r1;
(c) i = r2; (d) r1 = d;
Упражнение 2.17. Что выводит следующий код?
int i, &ri = i;
i = 5; ri = 10;
std::cout << i << " " << ri << std::endl;
2.3.2. Указатели
Указатель (pointer) — это составной тип, переменная которого указывает на объект другого типа. Подобно ссылкам, указатели используются для косвенного доступа к другим объектам. В отличие от ссылок, указатель — это настоящий объект. Указатели могут быть присвоены и скопированы; один указатель за время своего существования может указывать на несколько разных объектов. В отличие от ссылки, указатель можно не инициализировать в момент определения. Подобно объектам других встроенных типов, значение неинициализированного указателя, определенного в области видимости блока, неопределенно.
Указатели зачастую трудно понять. При отладке проблемы, связанные с ошибками в указателях, способны запутать даже опытных программистов.
Тип указателя определяется оператором в форме *d, где d — определяемое имя. Символ * следует повторять для каждой переменной указателя.
int *ip1, *ip2; // ip1 и ip2 — указатели на тип int
double dp, *dp2; // dp2 — указатель на тип double;
// dp — переменная типа double
Получение адреса объекта
Указатель содержит адрес другого объекта. Для получения адреса объекта используется оператор обращения к адресу (address-of operator), или оператор &.
int ival = 42;
int *p = &ival; // p содержит адрес переменной ival;
// p - указатель на переменную ival
Второй оператор определяет p как указатель на тип int и инициализирует его адресом объекта ival типа int. Поскольку ссылки не объекты, у них нет адресов, а следовательно, невозможно определить указатель на ссылку.
За двумя исключениями, рассматриваемыми в разделах 2.4.2 и 15.2.3, типы указателя и объекта, на который он указывает, должны совпадать.
double dval;
double *pd = &dval; // ok: инициализатор - адрес объекта типа double
double *pd2 = pd; // ok: инициализатор - указатель на тип double
int *pi = pd; // ошибка: типы pi и pd отличаются
pi = &dval; // ошибка: присвоение адреса типа double
// указателю на тип int
Типы должны совпадать, поскольку тип указателя используется для выведения типа объекта, на который он указывает. Если бы указатель содержал адрес объекта другого типа, то выполнение операций с основным объектом потерпело бы неудачу.
Значение указателя
Хранимое в указателе значение (т.е. адрес) может находиться в одном из четырех состояний.
1. Оно может указывать на объект.
2. Оно может указывать на область непосредственно за концом объекта
3. Это может быть нулевое значение, означающее, что данный указатель не связан ни с одним объектом.
4. Оно может быть недопустимо. Любое иное значение, кроме приведенного выше, является недопустимым.
Копирование или иная попытка доступа к значению по недопустимому указателю является серьезной ошибкой. Как и использование неинициализированной переменной, компилятор вряд ли обнаружит эту ошибку. Результат доступа к недопустимому указателю непредсказуем. Поэтому всегда следует знать, допустим ли данный указатель.
Хотя указатели в случаях 2 и 3 допустимы, действия с ними ограничены. Поскольку эти указатели не указывают ни на какой объект, их нельзя использовать для доступа к объекту. Если все же сделать попытку доступа к объекту по такому указателю, то результат будет непредсказуем.
Использование указателя для доступа к объекту
Когда указатель указывает на объект, для доступа к этому объекту можно использовать оператор обращения к значению (dereference operator), или оператор *.
int ival = 42;
int *p = &ival; // p содержит адрес ival; p - указатель на ival
cout << *p; // * возвращает объект, на который указывает p;
// выводит 42
Обращение к значению указателя возвращает объект, на который указывает указатель. Присвоив значение результату оператора обращения к значению, можно присвоить его самому объекту.
*p = 0; // * возвращает объект; присвоение нового значения
// ival через указатель p
cout << *p; // выводит 0
При присвоении значения *p оно присваивается объекту, на который указывает указатель p.
Обратиться к значению можно только по допустимому указателю, который указывает на объект.
Ключевая концепция. У некоторых символов есть несколько значений
Некоторые символы, такие как & и * , используются и как оператор в выражении, и как часть объявления. Контекст, в котором используется символ, определяет то, что он означает.
int i = 42;
int &r = i; // & следует за типом в части объявления; r - ссылка
int *p; // * следует за типом в части объявления; p - указатель
p = &i; // & используется в выражении как оператор
// обращения к адресу
*p = i; // * используется в выражении как оператор
// обращения к значению
int &r2 = *p; // & в части объявления; * - оператор обращения к значению
В объявлениях символы & и * используются для формирования составных типов. В выражениях эти же символы используются для обозначения оператора. Поскольку тот же символ используется в совершенно ином смысле, возможно, стоит игнорировать внешнее сходство и считать их как будто различными символами.
Нулевые указатели
Нулевой указатель (null pointer) не указывает ни на какой объект. Код может проверить, не является ли указатель нулевым, прежде чем пытаться использовать его. Есть несколько способов получить нулевой указатель.
int *p1 = nullptr; // эквивалентно int *p1 = 0;
int *p2 = 0; // непосредственно инициализирует p2 литеральной
// константой 0, необходимо #include cstdlib
int *p3 = NULL; // эквивалентно int *p3 = 0;
Проще всего инициализировать указатель, используя литерал nullptr, который был введен новым стандартом. Литерал nullptr имеет специальный тип, который может быть преобразован (см. раздел 2.1.2) в любой другой ссылочный тип. В качестве альтернативы можно инициализировать указатель литералом 0, как это сделано в определении указателя p2.
Программисты со стажем иногда используют переменную препроцессора (preprocessor variable) NULL, которую заголовок cstdlib определяет как 0.
Немного подробней препроцессор рассматривается в разделе 2.6.3, а пока достаточно знать, что препроцессор (preprocessor) — это программа, которая выполняется перед компилятором. Переменные препроцессора используются препроцессором, они не являются частью пространства имен std, поэтому их указывают непосредственно, без префикса std::.
При использовании переменной препроцессора последний автоматически заменяет такую переменную ее значением. Следовательно, инициализация указателя переменной NULL эквивалентна его инициализации значением 0. Сейчас программы С++ вообще должны избегать применения переменной NULL и использовать вместо нее литерал nullptr.
Нельзя присваивать переменную типа int указателю, даже если ее значением является 0.
int zero = 0;
pi = zero; // ошибка: нельзя присвоить переменную типа int указателю
Совет. Инициализируйте все указатели
Неинициализированные указатели — обычный источник ошибок времени выполнения.
Подобно любой другой неинициализированной переменной, последствия использования неинициализированного указателя непредсказуемы. Использование неинициализированного указателя почти всегда приводит к аварийному отказу во время выполнения. Однако поиск причин таких отказов может оказаться на удивление трудным.
У большинства компиляторов при использовании неинициализированного указателя биты в памяти, где он располагается, используются как адрес. Использование неинициализированного указателя — это попытка доступа к несуществующему объекту в произвольной области памяти. Нет никакого способа отличить допустимый адрес от недопустимого, состоящего из случайных битов, находящихся в той области памяти, которая была зарезервирована для указателя.
Авторы рекомендуют инициализировать все переменные, а особенно указатели. Если это возможно, определяйте указатель только после определения объекта, на который он должен указывать. Если связываемого с указателем объекта еще нет, то инициализируйте указатель значением nullptr или 0 . Так код программы может узнать, что указатель не указывает на объект.
Присвоение и указатели
И указатели, и ссылки предоставляют косвенный доступ к другим объектам. Однако есть важные различия в способе, которым они это делают. Самое важное то, что ссылка — это не объект. После того как ссылка определена, нет никакого способа заставить ее ссылаться на другой объект. При использовании ссылки всегда используется объект, с которым она была связана первоначально.
Между указателем и содержащимся в нем адресом нет такой связи. Подобно любой другой (нессылочной) переменной, при присвоении указателя для него устанавливается новое значение. Присвоение заставляет указатель указывать на другой объект.
int i = 42;
int *pi = 0; // указатель pi инициализирован, но не адресом объекта
int *pi2 = &i; // указатель pi2 инициализирован адресом объекта i
int *pi3; // если pi3 определен в блоке, pi3 не инициализирован
pi3 = pi2; // pi3 и pi2 указывают на тот же объект, т.е. на i
pi2 = 0; // теперь pi2 не содержит адреса никакого объекта
Сначала может быть трудно понять, изменяет ли присвоение указатель или сам объект, на который он указывает. Важно не забывать, что присвоение изменяет свой левый операнд. Следующий код присваивает новое значение переменной pi, что изменяет адрес, который она хранит:
pi = &ival; // значение pi изменено; теперь pi указывает на ival
С другой стороны, следующий код (использующий *pi, т.е. значение, на которое указывает указатель pi) изменяет значение объекта:
*pi = 0; // значение ival изменено; pi неизменен
Другие операции с указателями
Пока значение указателя допустимо, его можно использовать в условии. Аналогично использованию арифметических значений (раздел 2.1.2), если указатель содержит значение 0, то условие считается ложным.
int ival = 1024;
int *pi = 0; // pi допустим, нулевой указатель
int *pi2 = &ival; // pi2 допустим, содержит адрес ival
if (pi) // pi содержит значение 0, условие считается ложным
// ...
if (pi2) // pi2 указывает на ival, значит, содержит не 0;
// условие считается истинным
// ...
Любой отличный от нулевого указатель рассматривается как значение true. Два допустимых указателя того же типа можно сравнить, используя операторы равенства (==) и неравенства (!=). Результат этих операторов имеет тип bool. Два указателя равны, если они содержат одинаковый адрес, и неравны в противном случае. Два указателя содержат одинаковый адрес (т.е. равны), если они оба нулевые, если они указывают на тот же объект или на область непосредственно за концом того же объекта. Обратите внимание, что указатель на объект и указатель на область за концом другого объекта вполне могут содержать одинаковый адрес. Такие указатели равны.
Поскольку операции сравнения используют значения указателей, эти указатели должны быть допустимы. Результат использования недопустимого указателя в условии или в сравнении непредсказуем.
Дополнительные операции с указателями будут описаны в разделе 3.5.3.
Тип void* является специальным типом указателя, способного содержать адрес любого объекта. Подобно любому другому указателю, указатель void* содержит адрес, но тип объекта по этому адресу неизвестен.
double obj = 3.14, *pd = &obj;
// ok: void* может содержать адрес любого типа данных
void *pv = &obj; // obj может быть объектом любого типа
pv = pd; // pv может содержать указатель на любой тип
С указателем void* допустимо немного действий: его можно сравнить с другим указателем, можно передать его функции или возвратить из нее либо присвоить другому указателю типа void*. Его нельзя использовать для работы с объектом, адрес которого он содержит, поскольку неизвестен тип объекта, неизвестны и операции, которые можно с ним выполнять.
Как правило, указатель void* используют для работы с памятью как с областью памяти, а не для доступа к объекту, хранящемуся в этой области. Использование указателей void* рассматривается в разделе 19.1.1, а в разделе 4.11.3 продемонстрировано, как можно получить адрес, хранящийся в указателе void*.
Упражнения раздела 2.3.2
Упражнение 2.18. Напишите код, изменяющий значение указателя. Напишите код для изменения значения, на которое указывает указатель.
Упражнение 2.19. Объясните основные отличия между указателями и ссылками.
Упражнение 2.20. Что делает следующая программа?
int i = 42;
int *p1 = &i;
*p1 = *p1 * *p1;
Упражнение 2.21. Объясните каждое из следующих определений. Укажите, все ли они корректны и почему.
int i = 0;
(a) double* dp = &i; (b) int *ip = i; (c) int *p = &i;
Упражнение 2.22. С учетом того, что p является указателем на тип int, объясните следующий код:
if (p) // ...
if (*p) // ...
Упражнение 2.23. Есть указатель p, можно ли определить, указывает ли он на допустимый объект? Если да, то как? Если нет, то почему?
Упражнение 2.24. Почему инициализация указателя p допустима, а указателя lp нет?
int i = 42; void *p = &i; long *lp = &i;
2.3.3. Понятие описаний составных типов
Как уже упоминалось, определение переменной состоит из указания базового типа и списка операторов объявления. Каждый оператор объявления может связать свою переменную с базовым типом отлично от других операторов объявления в том же определении. Таким образом, одно определение может определять переменные отличных типов.
// i - переменная типа int; p - указатель на тип int;
// r - ссылка на тип int
int i = 1024, *p = &i, &r = i;
Многие программисты не понимают взаимодействия базового и модифицированного типа, который может быть частью оператора объявления.
#magnify.png Определение нескольких переменных
Весьма распространенное заблуждение полагать, что модификатор типа (* или &) применяется ко всем переменным, определенным в одном операторе. Частично причина в том, что между модификатором типа и объявляемым именем может находиться пробел.
int* p; // вполне допустимо, но может ввести в заблуждение
Данное определение может ввести в заблуждение потому, что создается впечатление, будто int* является типом каждой переменной, объявленной в этом операторе. Несмотря на внешний вид, базовым типом этого объявления является int, а не int*. Символ * — это модификатор типа p, он не имеет никакого отношения к любым другим объектам, которые могли бы быть объявлены в том же операторе:
int* p1, p2; // p1 - указатель на тип int; p2 - переменная типа int
Есть два общепринятых стиля определения нескольких переменных с типом указателя или ссылки. Согласно первому, модификатор типа располагается рядом с идентификатором:
int *p1, *p2; // p1 и p2 — указатели на тип int
Этот стиль подчеркивает, что переменная имеет составной тип. Согласно второму, модификатор типа располагается рядом с типом, но он определяет только одну переменную в операторе:
int* p1; // p1 - указатель на тип int
int* p2; // p2 - указатель на тип int
Этот стиль подчеркивает, что объявление определяет составной тип.
Нет никакого единственно правильного способа определения указателей и ссылок. Важно неукоснительно придерживаться выбранного стиля.
В этой книге используется первый стиль, знак * (или &) помещается рядом с именем переменной.
Указатели на указатели
Теоретически нет предела количеству модификаторов типа, применяемых в операторе объявления. Когда модификаторов более одного, они объединяются хоть и логичным, но не всегда очевидным способом. В качестве примера рассмотрим указатель. Указатель — это объект в памяти, и, как у любого объекта, у этого есть адрес. Поэтому можно сохранить адрес указателя в другом указателе.
Каждый уровень указателя отмечается собственным символом *. Таким образом, для указателя на указатель пишут **, для указателя на указатель на указатель — *** и т.д.
int ival = 1024;
int *pi = &ival; // pi указывает на переменную типа int
int **ppi = π // ppi указывает на указатель на переменную типа int
Здесь pi — указатель на переменную типа int, a ppi — указатель на указатель на переменную типа. Эти объекты можно было бы представить так:
#img_35.png_0
Подобно тому, как обращение к значению указателя на переменную типа int возвращает значение типа int, обращение к значению указателя на указатель возвращает указатель. Для доступа к основной объекту в этом случае необходимо обратиться к значению указателя дважды:
cout << "The value of ival\n"
<< "direct value: " << ival << "\n"
<< "indirect value: " << *pi << "\n"
<< "doubly indirect value: " << **ppi << endl;
Эта программа выводит значение переменной ival тремя разными способами: сначала непосредственно, затем через указатель pi на тип int и наконец обращением к значению указателя ppi дважды, чтобы добраться до основного значения в переменной ival.
Ссылки на указатели
Ссылка — не объект. Следовательно, не может быть указателя на ссылку. Но поскольку указатель — это объект, вполне можно определить ссылку на указатель.
int i = 42;
int *p; // p - указатель на тип int
int *&r = p; // r - ссылка на указатель p
r = &i; // r ссылается на указатель;
// присвоение &i ссылке r делает p указателем на i
*r = 0; // обращение к значению r дает i, объект, на который
// указывает p; изменяет значение i на 0
Проще всего понять тип r — прочитать определение справа налево. Ближайший символ к имени переменной (в данном случае & в &r) непосредственно влияет на тип переменной. Таким образом, становится ясно, что r является ссылкой. Остальная часть оператора объявления определяет тип, на который ссылается ссылка r. Следующий символ, в данном случае *, указывает, что тип r относится к типу указателя. И наконец, базовый тип объявления указывает, что r — это ссылка на указатель на переменную типа int.
Сложное объявление указателя или ссылки может быть проще понять, если читать его справа налево.
Упражнения раздела 2.3.3
Упражнение 2.25. Определите типы и значения каждой из следующих переменных:
(a) int* ip, &r = ip; (b) int i, *ip = 0; (c) int* ip, ip2;
2.4. Спецификатор
const
Иногда необходимо определить переменную, значение которой, как известно, не может быть изменено. Например, можно было бы использовать переменную, хранящую размер буфера. Использование переменной облегчит изменение размера буфера, если мы решим, что исходный размер нас не устраивает. С другой стороны, желательно предотвратить непреднамеренное изменение в коде значения этой переменной. Значение этой переменной можно сделать неизменным, используя в ее определении спецификатор const (qualifier const):
const int bufSize = 512; // размер буфера ввода
Это определит переменную bufSize как константу. Любая попытка присвоить ей значение будет ошибкой:
bufSize = 512; // ошибка: попытка записи в константный объект
Поскольку нельзя изменить значение константного объекта после создания, его следует инициализировать. Как обычно, инициализатор может быть выражением любой сложности:
const int i = get_size(); // ok: инициализация во время выполнения
const int j = 42; // ok: инициализация во время компиляции
const int k; // ошибка: k - неинициализированная константа
Инициализация и константы
Как уже упоминалось не раз, тип объекта определяет операции, которые можно с ним выполнять. Константный тип можно использовать для большинства, но не для всех операций, как и его неконстантный аналог. Ограничение одно — можно использовать только те операции, которые неспособны изменить объект. Например, тип const int можно использовать в арифметических выражениях точно так же, как обычный неконстантный тип int. Тип const int преобразуется в тип bool тем же способом, что и обычный тип int, и т.д.
К операциям, не изменяющим значение объекта, относится инициализация. При использовании объекта для инициализации другого объекта не имеет значения, один или оба из них являются константами.
int i = 42;
const int ci = i; // ok: значение i копируется в ci
int j = ci; // ok: значение ci копируется в j
Хотя переменная ci имеет тип const int, ее значение имеет тип int. Константность переменной ci имеет значение только для операций, которые могли бы изменить ее значение. При копировании переменной ci для инициализации переменной j ее константность не имеет значения. Копирование объекта не изменяет его. Как только копия сделана, у нового объекта нет никакой дальнейшей связи с исходным объектом.
По умолчанию константные объекты локальны для файла
Когда константный объект инициализируется константой во время компиляции, такой как bufSize в определении ниже, компилятор обычно заменяет используемую переменную ее значением во время компиляции.
const int bufSize = 512; // размер буфера ввода
Таким образом, компилятор создаст исполняемый код, использующий значение 512 в тех местах, где исходный код использует переменную bufSize.
Чтобы заменить переменную значением, компилятор должен видеть ее инициализатор. При разделении программы на несколько файлов, в каждом из которых используется константа, необходим доступ к ее инициализатору. Для этого переменная должна быть определена в каждом файле, в котором используется ее значение (см. раздел 2.2.2). Для обеспечения такого поведения, но все же без повторных определений той же переменной, константные переменные определяются как локальные для файла. Определение константы с тем же именем в нескольких файлах подобно написанию определения для отдельных переменных в каждом файле.
Иногда константу необходимо совместно использовать в нескольких файлах, однако ее инициализатор не является константным выражением. Мы не хотим, чтобы компилятор создал отдельную переменную в каждом файле, константный объект должен вести себя как другие (не константные) переменные. В таком случае определить константу следует в одном файле, и объявить ее в других файлах, где она тоже используется.
Для определения единого экземпляра константной переменной используется ключевое слово extern как в ее определении, так и в ее объявлениях.
// Файл file_1.cc. Определение и инициализация константы, которая
// доступна для других файлов
extern const int bufSize = fcn();
// Файл file_1.h
extern const int bufSize; // та же bufSize, определенная в file_1.cc
Здесь переменная bufSize определяется и инициализируется в файле file_1.cc. Поскольку это объявление включает инициализатор, оно (как обычно) является и определением. Но поскольку bufSize константа, необходимо применить ключевое слово extern, чтобы использовать ее в других файлах.
Объявление в заголовке file_1.h также использует ключевое слово extern. В данном случае это демонстрирует, что имя bufSize не является локальным для этого файла и что его определение находится в другом месте.
Чтобы совместно использовать константный объект в нескольких файлах, его необходимо определить с использованием ключевого слова extern.
Упражнения раздела 2.4
Упражнение 2.26. Что из приведенного ниже допустимо? Если что-то недопустимо, то почему?
(a) const int buf; (b) int cnt = 0;
(c) const int sz = cnt; (d) ++cnt; ++sz;
2.4.1. Ссылка на константу
Подобно любым другим объектам, с константным объектом можно связать ссылку. Для этого используется ссылка на константу (reference to const), т.е. ссылка на объект типа const. В отличие от обычной ссылки, ссылку на константу нельзя использовать для изменения объекта, с которым она связана.
const int ci = 1024;
const int &r1 = ci; // ok: и ссылка, и основной объект - константы
r1 = 42; // ошибка: r1 - ссылка на константу
int &r2 = ci; // ошибка: неконстантная ссылка на константный объект
Поскольку нельзя присвоить значение самой переменной ci, ссылка также не должна позволять изменять ее. Поэтому инициализация ссылки r2 — это ошибка. Если бы эта инициализация была допустима, то ссылку r2 можно было бы использовать для изменения значения ее основного объекта.
Терминология. Константная ссылка — это ссылка на константу
Программисты С++, как правило, используют термин константная ссылка (const reference), однако фактически речь идет о ссылке на константу (reference to const).
С технической точки зрения нет никаких константных ссылок. Ссылка — не объект, поэтому саму ссылку нельзя сделать константой. На самом деле, поскольку нет никакого способа заставить ссылку ссылаться на другой объект, то в некотором смысле все ссылки — константы. То, что ссылка ссылается на константный или неконстантный тип, относится к тому, что при помощи этой ссылки можно сделать, однако привязку самой ссылки изменить нельзя в любом случае.
Инициализация и ссылки на константу
В разделе 2.1.2 мы обращали ваше внимание на два исключения из правила, согласно которому тип ссылки должен совпадать с типом объекта, на который она ссылается. Первое исключение: мы можем инициализировать ссылку на константу результатом выражения, тип которого может быть преобразован (см. раздел 2.1.2) в тип ссылки. В частности, мы можем связать ссылку на константу с неконстантным объектом, литералом или более общим выражением:
int i = 42;
const int &r1 = i; // можно связать ссылку const int& с обычным
// объектом int
const int &r2 =42; // ok: r1 - ссылка на константу
const int &r3 = r1 * 2; // ok: r3 - ссылка на константу
int &r4 = r * 2; // ошибка: r4 - простая, неконстантная ссылка
Простейший способ понять это различие в правилах инициализации — рассмотреть то, что происходит при связывании ссылки с объектом другого типа:
double dval = 3.14;
const int &ri = dval;
Здесь ссылка ri ссылается на переменную типа int. Операции со ссылкой ri будут целочисленными, но переменная dval содержит число с плавающей запятой, а не целое число. Чтобы удостовериться в том, что объект, с которым связана ссылка ri, имеет тип int, компилятор преобразует этот код в нечто следующее:
const int temp = dval; // создать временную константу типа int из
// переменной типа double
const int &ri = temp; // связать ссылку ri с временной константой
В данном случае ссылка ri связана с временным объектом (temporary). Временный объект — это безымянный объект, создаваемый компилятором для хранения промежуточного результата вычисления. Программисты С++ зачастую используют слово "temporary" как сокращение термина "temporary object".
Теперь рассмотрим, что могло бы произойти, будь инициализация позволена, но ссылка ri не была бы константной. В этом случае мы могли бы присвоить значение по ссылке ri. Это изменило бы объект, с которым связана ссылка ri. Этот временный объект имеет тип не dval. Программист, заставивший ссылку ri ссылаться на переменную dval, вероятно, ожидал, что присвоение по ссылке ri изменит переменную dval. В конце концов, почему произошло присвоение по ссылке ri, если не было намерения изменять объект, с которым она связана? Поскольку связь ссылки с временным объектом осуществляет уж конечно не то, что подразумевал программист, язык считает это некорректным.
Ссылка на константу может ссылаться на неконстантный объект
Важно понимать, что ссылка на константу ограничивает только то, что при помощи этой ссылки можно делать. Привязка ссылки к константному объекту ничего не говорит о том, является ли сам основной объект константой. Поскольку основной объект может оказаться неконстантным, он может быть изменен другими способами:
int i = 42;
int &r1 = i; // r1 связана с i
const int &r2 = i; // r2 тоже связана с i;
// но она не может использоваться для изменения i
r1 = 0; // r1 - неконстантна; i теперь 0
r2 = 0; // ошибка: r2 - ссылка на константу
Привязка ссылки r2 к неконстантной переменной i типа int вполне допустима. Но ссылку r2 нельзя использовать для изменения значения переменной i. Несмотря на это, значение переменной i вполне можно изменить другим способом, Например, можно присвоить ей значение непосредственно или при помощи другой связанной с ней ссылки, такой как r1.
2.4.2. Указатели и спецификатор
const
Подобно ссылкам, вполне возможно определять указатели, которые указывают на объект константного или неконстантного типа. Как и ссылку на константу (см. раздел 2.4.1), указатель на константу (pointer to const) невозможно использовать для изменения объекта, на который он указывает. Адрес константного объекта можно хранить только в указателе на константу:
const double pi = 3.14; // pi - константа; ее значение неизменно
double *ptr = π // ошибка: ptr - простой указатель
const double *cptr = π // ok: cptr может указывать на тип
// const double
*cptr = 42; // ошибка: нельзя присвоить *cptr
В разделе 2.3.2 упоминалось о наличии двух исключений из правила, согласно которому типы указателя и объекта, на который он указывает, должны совпадать. Первое исключение — это возможность использования указателя на константу для указания на неконстантный объект:
double dval = 3.14; // dval типа double; ее значение неизменно
cptr = &dval; // ok: но изменить dval при помощи cptr нельзя
Подобно ссылке на константу, указатель на константу ничего не говорит о том, является ли объект, на который он указывает, константой. Определение указателя как указателя на константу влияет только на то, что с его помощью можно сделать. Не забывайте, что нет никакой гарантии того, что объект, на который указывает указатель на константу, не будет изменяться.
Возможно, указатели и ссылки на константы следует рассматривать как указатели или ссылки, "которые полагают, что они указывают или ссылаются на константы".
Константные указатели
В отличие от ссылок, указатели — это объекты. Следовательно, подобно любым другим объектам, вполне может быть указатель, сам являющийся константой. Как и любой другой константный объект, константный указатель следует инициализировать, после чего изменить его значение (т.е. адрес, который он содержит) больше нельзя. Константный указатель объявляют, расположив ключевое слово const после символа *. Это означает, что данный указатель является константой, а не обычным указателем на константу.
int errNumb = 0;
int *const curErr = &errNumb; // curErr всегда будет указывать на errNumb
const double pi = 3.14159;
const double *const pip = π // pip константный указатель на
// константный объект
Как уже упоминалось в разделе 2.3.3, проще всего понять эти объявления, читая их справа налево. В данном случае ближе всего к имени curErr расположен спецификатор const, означая, что сам объект curErr будет константным. Тип этого объекта формирует остальная часть оператора объявления. Следующий символ оператора объявления, *, означает, что curErr — это константный указатель. И наконец, объявление завершает базовый тип, означая, что curErr — это константный указатель на объект типа int. Аналогично pip — это константный указатель на объект типа const double.
Тот факт, что указатель сам является константой, ничто не говорит о том, можем ли мы использовать указатель для изменения основного объекта. Возможность изменения объекта полностью зависит от типа, на который указывает указатель. Например, pip — это константный указатель на константу. Ни значение объекта, на который указывает указатель pip, ни хранящийся в нем адрес не могут быть изменены. С другой стороны, указатель curErr имеет простой, неконстантный тип int. Указатель curErr можно использовать для изменения значения переменной errNumb:
*pip = 2.72; // ошибка: pip - указатель на константу
// если значение объекта, на который указывает указатель curErr
// (т.е. errNumb), отлично от нуля
if (*curErr) {
errorHandler();
*curErr = 0; // обнулить значение объекта, на который
// указывает указатель curErr
}
Упражнения раздела 2.4.2
Упражнение 2.27. Какие из следующих инициализаций допустимы? Объясните почему.
(a) int i = -1, &r = 0; (b) int *const p2 = &i2;
(c) const int i = -1, &r = 0; (d) const int *const p3 = &i2;
(e) const int *p1 = &i2; (f) const int &const r2;
(g) const int i2 = i, &r = i;
Упражнение 2.28. Объясните следующие определения. Какие из них недопустимы?
(a) int i, *const cp; (b) int *p1, *const p2;
(c) const int ic, &r = ic; (d) const int *const p3;
(e) const int *p;
Упражнение 2.29. С учетом переменных из предыдущих упражнений, какие из следующих присвоений допустимы? Объясните почему.
(a) i = ic; (b) pi = p3;
(с) pi = ⁣ (d) p3 = ⁣
(e) p2 = pi; (f) ic = *p3;
2.4.3. Спецификатор
const
верхнего уровня
Как уже упоминалось, указатель — это объект, способный указывать на другой объект. В результате можно сразу сказать, является ли указатель сам константой и являются ли константой объекты, на которые он может указывать. Термин спецификатор const верхнего уровня (top-level const) используется для обозначения того ключевого слова const, которое объявляет константой сам указатель. Когда указатель способен указывать на константный объект, это называется спецификатор const нижнего уровня (low-level const).
В более общем смысле спецификатор const верхнего уровня означает, что объект сам константа. Спецификатор const верхнего уровня может присутствовать в любом типе объекта, будь то один из встроенных арифметических типов, тип класса или ссылочный тип. Спецификатор const нижнего уровня присутствует в базовом типе составных типов, таких как указатели или ссылки. Обратите внимание, что ссылочные типы, в отличие от большинства других типов, способны иметь спецификаторы const как верхнего, так и нижнего уровня, независимо друг от друга.
int i = 0;
int *const pi = &i; // нельзя изменить значение pi;
// const верхнего уровня
const int ci = 42; // нельзя изменить ci; const верхнего уровня
const int *p2 = &ci; // нельзя изменить p2; const нижнего уровня
const int *const p3 = p2; // справа const верхнего уровня, слева нет
const int &r = ci; // const в ссылочных типах всегда нижнего уровня
Различие между спецификаторами const верхнего и нижнего уровней проявляется при копировании объекта. При копировании объекта спецификатор const верхнего уровня игнорируется.
i = ci; // ok: копирование значения ci; спецификатор const верхнего
// уровня в ci игнорируется
p2 = p3; // ok: указываемые типы совпадают; спецификатор const верхнего
// уровня в p3 игнорируется
Копирование объекта не изменяет копируемый объект. Поэтому несущественно, является ли копируемый или копирующий объект константой.
Спецификатор const нижнего уровня, напротив, никогда не игнорируется. При копировании объектов у них обоих должны быть одинаковые спецификаторы const нижнего уровня, или должно быть возможно преобразование между типами этих двух объектов. Как правило, преобразование неконстанты в константу возможно, но не наоборот.
int *p = p3; // ошибка: p3 имеет const нижнего уровня, а p - нет
p2 = p3; // ok: p2 имеет то же const нижнего уровня, что и p3
p2 = &i; // ok: преобразование int* в const int* возможно
int &r = ci; // ошибка: невозможно связать обычную int& с
// объектом const int
const int &r2 = i; // ok: const int& можно связать с обычным int
У указателя p3 есть спецификатор const нижнего и верхнего уровня. При копировании указателя p3 можно проигнорировать его спецификатор const верхнего уровня, но не тот факт, что он указывает на константный тип. Следовательно, нельзя использовать указатель p3 для инициализации указателя p, который указывает на простой (неконстантный) тип int. С другой стороны, вполне можно присвоить указатель p3 указателю p2. У обоих указателей тот же тип (спецификатор const нижнего уровня). Тот факт, что p3 — константный указатель (т.е. у него есть спецификатор const верхнего уровня), не имеет значения.
Упражнения раздела 2.4.3
Упражнение 2.30. Укажите по каждому из следующих объявлений, имеет ли объявляемый объект спецификатор const нижнего или верхнего уровня.
const int v2 = 0;
int v1 = v2;
int *p1 = &v1, &r1 = v1;
const int *p2 = &v2, *const p3 = &i, &r2 = v2;
Упражнение 2.31. С учетом объявлений в предыдущем упражнении укажите, допустимы ли следующие присвоения. Объясните, как спецификатор const верхнего или нижнего уровня применяется в каждом случае.
r1 = v2;
p1 = p2; р2 = p1;
p1 = p3; p2 = p3;
2.4.4. Переменные
constexpr
и константные выражения
Константное выражение (constant expression) — это выражение, значение которого не может измениться и вычисляется во время компиляции. Литерал — это константное выражение. Константный объект, инициализируемый константным выражением, также является константным выражением. Вскоре мы увидим, что в языке есть несколько контекстов, требующих константных выражений.
Является ли данный объект (или выражение) константным выражением, зависит от типов и инициализаторов. Например:
const int max_files = 20; // max_files - константное выражение
const int limit = max_files + 1; // limit - константное выражение
int staff_size = 27; // staff_size - неконстантное выражение
const int sz = get_size(); // sz - неконстантное выражение
Хотя переменная staff_size инициализируется литералом, это неконстантное выражение, поскольку он имеет обычный тип int, а не const int. С другой стороны, хоть переменная sz и константа, значение ее инициализатора неизвестно до времени выполнения. Следовательно, это неконстантное выражение.
Переменные constexpr
В большой системе может быть трудно утверждать (наверняка), что инициализатор — константное выражение. Константная переменная могла бы быть определена с инициализатором, который мы полагаем константным выражением. Однако при использовании этой переменной в контексте, требующем константного выражения, может оказаться, что инициализатор не был константным выражением. Как правило, определение объекта и его использования в таком контексте располагаются довольно далеко друг от друга.
Согласно новому стандарту, можно попросить компилятор проверить, является ли переменная константным выражением, использовав в ее объявлении ключевое слово constexpr. Переменные constexpr неявно являются константой и должны инициализироваться константными выражениями.
constexpr int mf = 20; // 20 - константное выражение
constexpr int limit = mf + 1; // mf + 1 - константное выражение
constexpr int sz = size(); // допустимо, только если size() является
// функцией constexpr
Хоть и нельзя использовать обычную функцию как инициализатор для переменной constexpr, как будет описано в разделе 6.5.2, новый стандарт позволяет определять функции как constexpr. Такие функции должны быть достаточно просты, чтобы компилятор мог выполнить их во время компиляции. Функции constexpr можно использовать в инициализаторе переменной constexpr.
Как правило, ключевое слово constexpr имеет смысл использовать для переменных, которые предполагается использовать как константные выражения.
Литеральные типы
Поскольку константное выражение обрабатывается во время компиляции, есть пределы для типов, которые можно использовать в объявлении constexpr. Типы, которые можно использовать в объявлении constexpr, известны как литеральные типы (literal type), поскольку они достаточно просты для литеральных значений.
Все использованные до сих пор типы — арифметический, ссылка и указатель — это литеральные типы. Наш класс Sales_item и библиотечный тип string не относятся к литеральным типам. Следовательно, нельзя определить переменные этих типов как constexpr. Другие виды литеральных типов рассматриваются в разделах 7.5.6 и 19.3.
Хотя указатели и ссылки можно определить как constexpr, используемые для их инициализации объекты жестко ограничены. Указатель constexpr можно инициализировать литералом nullptr или литералом (т.е. константным выражением) 0. Можно также указать на (или связать с) объект, который остается по фиксированному адресу.
По причинам, рассматриваемым в разделе 6.1.1, определенные в функции переменные обычно не хранятся по фиксированному адресу. Следовательно, нельзя использовать указатель constexpr для указания на такие переменные. С другой стороны, адрес объекта, определенного вне любой функции, является константным выражением и, таким образом, может использоваться для инициализации указателя constexpr. Как будет описано в разделе 6.1.1, функции могут определять переменные, существующие на протяжении нескольких вызовов этой функция. Как и объект, определенный вне любой функции, эти специальные локальные объекты также имеют фиксированные адреса. Поэтому и ссылка constexpr может быть связана с такой переменной, и указатель constexpr может содержать ее адрес.
Указатели и спецификатор constexpr
Важно понимать, что при определении указателя в объявлении constexpr спецификатор constexpr относится к указателю, а не к типу, на который указывает указатель.
const int *p = nullptr; // p - указатель на const int
constexpr int *q = nullptr; // q - константный указатель на int
Несмотря на внешний вид, типы p и q весьма различны; p — указатель на константу, тогда как q — константный указатель. Различие является следствием того факта, что спецификатор constexpr налагает на определяемый объект спецификатор const верхнего уровня (см. раздел 2.4.3).
Как и любой другой константный указатель, указатель constexpr может указать на константный или неконстантный тип.
constexpr int *np = nullptr; // np - нулевой константный указатель
// на int
int j = 0;
constexpr int i = 42; // типом i является const int
// i и j должны быть определены вне любой функции
constexpr const int *p = &i; // p - константный указатель
// на const int i
constexpr int *p1 = &j; // p1 - константный указатель на int j
Упражнения раздела 2.4.4
Упражнение 2.32. Допустим ли следующий код? Если нет, то как его исправить?
int null = 0, *p = null;
2.5. Работа с типами
По мере усложнения программ используемые в них типы также становятся все более сложными. Осложнения в использовании типов возникают по двум причинам. Имена некоторых типов трудно писать по памяти. Написание некоторых их форм утомительно и подвержено ошибкам. Кроме того, формат записи сложного типа способен скрыть его цель или значение. Другой источник осложнений кроется в том, что иногда трудно точно определить необходимый тип. Это может потребовать оглянуться на контекст программы.
2.5.1. Псевдонимы типов
Псевдоним типа (type alias) — это имя, являющееся синонимом имени другого типа. Псевдонимы типа позволяют упростить сложные определения типов, облегчая их использование. Псевдонимы типа позволяют также подчеркивать цель использования типа. Определить псевдоним типа можно одним из двух способов. Традиционно он определяется при помощи ключевого слова typedef:
typedef double wages; // wages - синоним для double
typedef wages base, *p; // base - синоним для double, a p - для double*
Ключевое слово typedef может быть частью базового типа в объявлении (см. раздел 2.3). Объявления, включающие ключевое слово typedef, определяют псевдонимы типа, а не переменные. Как и в любое другое объявление, в это можно включать модификаторы типа, которые определяют составные типы, включающие базовый тип.
Новый стандарт вводит второй способ определения псевдонима типа при помощи объявления псевдонима (alias declaration) и знака =.
using SI = Sales_item; // SI - синоним для Sales_item
Объявление псевдонима задает слева от оператора = имя псевдонима типа, который расположен справа.
Псевдоним типа — это имя типа, оно может присутствовать везде, где присутствует имя типа.
wages hourly, weekly; // то же, что и double hourly, weekly;
SI item; // то же, что и Sales_item item
#magnify.png Указатели, константы и псевдонимы типа
Объявления, использующие псевдонимы типа, представляющие составные типы и константы, могут приводить к удивительным результатам. Например, следующие объявления используют тип pstring, который является псевдонимом для типа char*.
typedef char *pstring;
const pstring cstr = 0; // cstr - константный указатель на char
const pstring *ps; // ps - указатель на константный указатель
// на тип char
Базовым типом в этих объявлениях является const pstring. Как обычно, модификатор const в базовом типе модифицирует данный тип. Тип pstring — это указатель на тип char, a const pstring — это константный указатель на тип char, но не указатель на тип const char.
Заманчиво, хоть и неправильно, интерпретировать объявление, которое использует псевдоним типа как концептуальную замену псевдонима, соответствующим ему типом:
const char *cstr = 0; // неправильная интерпретация const pstring cstr
Однако эта интерпретация неправильна. Когда используется тип pstring в объявлении, базовым типом объявления является тип указателя. При перезаписи объявления с использованием char*, базовым типом будет char, а * будет частью оператора объявления. В данном случае базовый тип — это const char. Перезапись объявляет cstr указателем на тип const char, а не константным указателем на тип char.
2.5.2. Спецификатор типа
auto
Нет ничего необычного в желании сохранить значение выражения в переменной. Чтобы объявить переменную, нужно знать тип этого выражения. Когда мы пишем программу, может быть на удивление трудно (а иногда даже невозможно) определить тип выражения. По новому стандарту можно позволить компилятору самому выяснять этот тип. Для этого используется спецификатор типа auto. В отличие от таких спецификаторов типа, как double, задающих определенный тип, спецификатор auto приказывает компилятору вывести тип из инициализатора. Само собой разумеется, у переменной, использующей спецификатор типа auto, должен быть инициализатор.
// тип item выводится из типа результата суммы val1 и val2
auto item = val1 + val2; // item инициализируется результатом val1 + val2
Здесь компилятор выведет тип переменной item из типа значения, возвращенного при применении оператора + к переменным val1 и val2. Если переменные val1 и val2 — объекты класса Sales_item (см. раздел 1.5), типом переменной item будет класс Sales_item. Если эти переменные имеют тип double, то у переменной item будет тип double и т.д.
Подобно любому другому спецификатору типа, используя спецификатор auto, можно определить несколько переменных. Поскольку объявление может задействовать только один базовый тип, у инициализаторов всех переменных в объявлении должны быть типы, совместимые друг с другом.
auto i = 0, *p = &i; // ok: i - int, а p - указатель на int
auto sz = 0, pi = 3.14; // ошибка: несовместимые типы у sz и pi
Составные типы, const и auto
Выводимый компилятором тип для спецификатора auto не всегда точно совпадает с типом инициализатора. Компилятор корректирует тип так, чтобы он соответствовал обычным правилам инициализации.
Во-первых, как уже упоминалось, при использовании ссылки в действительности используется объект, на который она ссылается. В частности, при использовании ссылки как инициализатора им является соответствующий объект. Компилятор использует тип этого объекта для выведения типа auto.
int i = 0, &r = i;
auto a = r; // a - int (r - псевдоним для i, имеющий тип int)
Во-вторых, выведение типа auto обычно игнорирует спецификаторы const верхнего уровня (см. раздел 2.4.3). Как обычно в инициализациях, спецификаторы const нижнего уровня учитываются в случае, когда инициализатор является указателем на константу.
const int ci = i, &cr = ci;
auto b = ci; // b - int (const верхнего уровня в ci отброшен)
auto с = cr; // с - int (cr - псевдоним для ci с const верхнего
// уровня)
auto d = &i; // d - int* (& объекта int - int*)
auto e = &ci; // e - const int* (& константного объекта - const нижнего
// уровня)
Если необходимо, чтобы у выведенного типа был спецификатор const верхнего уровня, его следует указать явно.
const auto f = ci; // выведенный тип ci - int; тип f - const int
Можно также указать, что необходима ссылка на автоматически выведенный тип. Обычные правила инициализации все еще применимы.
auto &g = ci; // g - const int&, связанный с ci
auto &h = 42; // ошибка: нельзя связать простую ссылку с литералом
const auto &j = 42; // ok: константную ссылку с литералом связать можно
Когда запрашивается ссылка на автоматически выведенный тип, спецификаторы const верхнего уровня в инициализаторе не игнорируются. Как обычно при связывании ссылки с инициализатором, спецификаторы const не относятся к верхнему уровню.
При определении нескольких переменных в том же операторе важно не забывать, что ссылка или указатель — это часть специфического оператора объявления, а не часть базового типа объявления. Как обычно, инициализаторы должны быть совместимы с автоматически выведенными типами:
auto k = ci, &l = i; // k - int; l - int&
auto &m = ci, *p = &ci; // m - const int&; p - указатель на const int
// ошибка: выведение типа из i - int;
// тип, выведенный из &ci - const int
auto &n = i, *p2 = &ci;
Упражнения раздела 2.5.2
Упражнение 2.33. С учетом определения переменных из этого раздела укажите то, что происходит в каждом из этих присвоений.
а = 42; b = 42; с = 42;
d = 42; е = 42; g = 42;
Упражнение 2.34. Напишите программу, содержащую переменные и присвоения из предыдущего упражнения. Выведите значения переменных до и после присвоений, чтобы проверить правильность предположений в предыдущем упражнении. Если они неправильны, изучите примеры еще раз и выясните, что привело к неправильному заключению.
Упражнение 2.35. Укажите типы, выведенные в каждом из следующих определений. Затем напишите программу, чтобы убедиться в своей правоте.
const int i = 42;
auto j = i; const auto &k = i; auto *p = &i;
const auto j2 = i, &k2 = i;
2.5.3. Спецификатор типа
decltype
Иногда необходимо определить переменную, тип которой компилятор выводит из выражения, но не использовать это выражение для инициализации переменной. Для таких случаев новый стандарт вводит спецификатор типа decltype, возвращающий тип его операнда. Компилятор анализирует выражение и определяет его тип, но не вычисляет его результат.
decltype(f()) sum = x; // sum имеет тот тип,
// который возвращает функция f
Здесь компилятор не вызывает функцию f(), но он использует тип, который возвратил бы такой вызов для переменной sum. Таким образом, компилятор назначает переменной sum тот же тип, который был бы возвращен при вызове функции f().
Таким образом, спецификатор decltype учитывает спецификатор const верхнего уровня и ссылки, но несколько отличается от того, как работает спецификатор auto. Когда выражение, к которому применен спецификатор decltype, является переменной, он возвращает тип этой переменной, включая спецификатор const верхнего уровня и ссылки.
const int ci = 0, &cj = ci;
decltype(ci) x = 0; // x имеет тип const int
decltype(cj) y = x; // y имеет тип const int& и связана с x
decltype(сj) z; // ошибка: z - ссылка, она должна быть инициализирована
Поскольку cj — ссылка, decltype (cj) — ссылочный тип. Как и любую другую ссылку, ссылку z следует инициализировать.
Следует заметить, что спецификатор decltype — единственный контекст, в котором переменная определена, поскольку ссылка не рассматривается как синоним объекта, на который она ссылается.
#books.png Спецификатор decltype и ссылки
Когда спецификатор decltype применяется к выражению, которое не является переменной, получаемый тип соответствует типу выражения. Как будет продемонстрировано в разделе 4.1.1, некоторые выражения заставят спецификатор decltype возвращать ссылочный тип. По правде говоря, спецификатор decltype возвращает ссылочный тип для выражений, результатом которых являются объекты, способные стоять слева от оператора присвоения.
// decltype выражение может быть ссылочным типом
int i = 42, *p = &i, &r = i;
decltype(r + 0) b; // ok: сложение возвращает тип int; b имеет тип int
// (не инициализирована)
decltype(*p) с; // ошибка: с имеет тип int& и требует инициализации
Здесь r — ссылка, поэтому decltype(r) возвращает ссылочный тип. Если необходим тип, на который ссылается ссылка r, можно использовать ее в таком выражении, как r + 0, поскольку оно возвращает значение не ссылочного типа.
С другой стороны, оператор обращения к значению — пример выражения, для которого спецификатор decltype возвращает ссылку. Как уже упоминалось, при обращении к значению указателя возвращается объект, на который он указывает. Кроме того, этому объекту можно присвоить значение. Таким образом, decltype(*p) выведет тип int&, а не просто int.
Еще одно важное различие между спецификаторами decltype и auto в том, что выведение, осуществляемое спецификатором decltype, зависит от формы данного выражения. Не всегда понимают то, что включение имени переменной в круглые скобки влияет на тип, возвращаемый спецификатором decltype. При применении спецификатора decltype к переменной без круглых скобок получается тип этой переменной. Если заключить имя переменной в одни или несколько круглых скобок, то компилятор будет рассматривать операнд как выражение. Переменная — это выражение, которое способно быть левым операндом присвоения. В результате спецификатор decltype для такого выражения возвратит ссылку.
// decltype переменной в скобках - всегда ссылка
decltype((i)) d; // ошибка: d - int& и должна инициализироваться
decltype(i) e; // ok: e имеет тип int (не инициализирована)
Помните, что спецификатор decltype(( переменная )) (обратите внимание на парные круглые скобки) всегда возвращает ссылочный тип, а спецификатор decltype( переменная ) возвращает ссылочный тип, только если переменная является ссылкой.
Упражнения раздела 2.5.3
Упражнение 2.36. Определите в следующем коде тип каждой переменной и значения, которые будет иметь каждая из них по завершении.
int а = 3, b = 4;
decltype(а) с = а;
decltype((b)) d = а;
++c;
++d;
Упражнение 2.37. Присвоение — это пример выражения, которое возвращает ссылочный тип. Тип — это ссылка на тип левого операнда. Таким образом, если переменная i имеет тип int, то выражение i = x имеет тип int&. С учетом этого определите тип и значение каждой переменной в следующем коде:
int а = 3, b = 4;
decltype(а) с = а;
decltype(а = b) d = а;
Упражнение 2.38. Опишите различия выведения типа спецификаторами decltype и auto. Приведите пример выражения, где спецификаторы auto и decltype выведут тот же тип, и пример, где они выведут разные типы.
2.6. Определение собственных структур данных
На самом простом уровне структура данных (data structure) — это способ группировки взаимосвязанных данных и стратегии их использования. Например, класс Sales_item группирует ISBN книги, количество проданных экземпляров и выручку от этой продажи. Он предоставляет также набор операций, таких как функция isbn() и операторы >>, <<, + и +=.
В языке С++ мы создаем собственные типы данных, определяя класс. Такие библиотечные типы, как string, istream и ostream, определены как классы, подобно типу Sales_item в главе 1. Поддержка классов в языке С++ весьма обширна, фактически части III и IV в значительной степени посвящены описанию средств, связанных с классами. Хотя класс Sales_item довольно прост, мы не сможем определить его полностью, пока не узнаем в главе 14, как писать собственные операторы.
2.6.1 Определение типа
Sales_data
Несмотря на то что мы еще не можем написать свой класс Sales_item полностью, уже вполне можно создать достаточно реалистичный класс, группирующий необходимые элементы данных. Стратегия использования этого класса заключается в том, что пользователи будут получать доступ непосредственно к элементам данных и смогут самостоятельно реализовать необходимые операции.
Поскольку создаваемая структура данных не поддерживает операций, назовем новую версию Sales_data, чтобы отличать ее от типа Sales_item. Определим класс следующим образом:
struct Sales_data {
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};
Класс начинается с ключевого слова struct, сопровождаемого именем класса и (возможно пустым) телом класса. Тело класса заключено в фигурные скобки и формирует новую область видимости (см. раздел 2.2.4). Определенные в классе имена должны быть уникальны в пределах класса, но вне класса они могут повторяться.
Ближайшие фигурные скобки, заключающие тело класса, следует сопроводить точкой с запятой. Точка с запятой необходима, так как после тела класса можно определить переменные:
struct Sales_data { /* ... */ } accum, trans, *salesptr;
// эквивалентно, но лучше определять эти объекты так
struct Sales_data { /* ... */ };
Sales data accum, trans, *salesptr;
Точка с запятой отмечает конец (обычно пустого) списка объявления операторов. Обычно определение объекта в составе определения класса — это не лучшая идея. Объединение в одном операторе определений двух разных сущностей (класса и переменной) ухудшает читабельность кода.
Забытая точка с запятой в конце определения класса — довольно распространенная ошибка начинающих программистов.
Переменные-члены класса
В теле класса определены члены (member) класса. У нашего класса есть только переменные-члены (data member). Переменные-члены класса определяют содержимое объектов этого класса. Каждый объект обладает собственным экземпляром переменных-членов класса. Изменение переменных-членов одного объекта не изменяет данные в любом другом объекте класса Sales_data.
Переменные-члены определяют точно так же, как и обычные переменные: указывается базовый тип, затем список из одного или нескольких операторов объявления. У нашего класса будут три переменные-члены: член типа string по имени bookNo, член типа unsigned по имени units_sold и член типа double по имени revenue. Эти три переменные-члены будут у каждого объекта класса Sales_data.
По новому стандарту переменной-члену можно предоставить внутриклассовый инициализатор (in-class initializer). Он используется для инициализации переменных-членов при создании объектов. Члены без инициализатора инициализируются по умолчанию (см. раздел 2.2.1). Таким образом, при определении объектов класса Sales_data переменные-члены units_sold и revenue будут инициализированы значением 0, а переменная-член bookNo — пустой строкой.
Внутриклассовые инициализаторы ограничены формой их использования (см. раздел 2.2.1): они должны либо быть заключены в фигурные скобки, либо следовать за знаком =. Нельзя определить внутриклассовый инициализатор в круглых скобках.
В разделе 7.2 указано, что язык С++ обладает еще одним ключевым словом, class, также используемым для определения собственной структуры данных. В этом разделе используем ключевое слово struct, поскольку пока еще не рассмотрены приведенные в главе 7 дополнительные средства, связанные с классом.
Упражнения раздела 2.6.1
Упражнение 2.39. Откомпилируйте следующую программу и посмотрите, что будет, если не поставить точку с запятой после определения класса. Запомните полученное сообщение, чтобы узнать его в будущем.
struct Foo { /* пусто */ } // Примечание: нет точки с запятой
int main() {
return 0;
}
Упражнение 2.40. Напишите собственную версию класса Sales_data.
2.6.2. Использование класса
Sales_data
В отличие от класса Sales_item, класс Sales_data не поддерживает операций. Пользователи класса Sales_data должны сами писать все операции, в которых они нуждаются. В качестве примера напишем новую версию программы из раздела 1.5.2, которая выводила сумму двух транзакций. Программа будет получать на входе такие транзакции:
0-201-78345-X 3 20.00
0-201-78345-X 2 25.00
Каждая транзакция содержит ISBN, количество проданных книг и цену, по которой была продана каждая книга.
Суммирование двух объектов класса Sales_data
Поскольку класс Sales_data не предоставляет операций, придется написать собственный код, осуществляющий ввод, вывод и сложение. Будем подразумевать, что класс Sales_data определен в заголовке Sales_data.h. Определение заголовка рассмотрим в разделе 2.6.3.
Так как эта программа будет длиннее любой, написанной до сих пор, рассмотрим ее по частям. В целом у программы будет следующая структура:
#include
#include
#include "Sales_data.h"
int main() {
Sales_data data1, data2;
// код чтения данных в data1 и data2
// код проверки наличия у data1 и data2 одинакового ISBN
// если это так, то вывести сумму data1 и data2
}
Как и первоначальная программа, эта начинается с включения заголовков, необходимых для определения переменных, содержащих ввод. Обратите внимание, что, в отличие от версии Sales_item, новая программа включает заголовок string. Он необходим потому, что код должен манипулировать переменной-членом bookNo типа string.
Чтение данных в объект класса Sales_data
Хотя до глав 3 и 10 мы не будем описывать библиотечный тип string подробно, упомянем пока лишь то, что необходимо знать для определения и использования члена класса, содержащего ISBN. Тип string содержит последовательность символов. Он имеет операторы >>, << и == для чтения, записи и сравнения строк соответственно. Этих знаний достаточно для написания кода чтения первой транзакции.
double price = 0; // цена за книгу, используемая для вычисления
// общей выручки
// читать первую транзакцию:
// ISBN, количество проданных книг, цена книги
std::cin >> data1.bookNo >> data1.units_sold >> price;
// вычислить общий доход из price и units_sold
data1.revenue = data1.units_sold * price;
Транзакции содержат цену, по которой была продана каждая книга, но структура данных хранит общий доход. Данные транзакции будем читать в переменную price (цена) типа double, исходя из которой и вычислим член revenue (доход).
std::cin >> data1.bookNo >> data1.units_sold >> price;
Для чтения значений членов bookNo и units_sold (продано экземпляров) объекта по имени data1 оператор ввода использует точечный оператор (см. раздел 1.5.2).
Последний оператор присваивает произведение data1.units_sold и price переменной-члену revenue объекта data1.
Затем программа повторяет тот же код для чтения данных в объект data2.
// читать вторую транзакцию
std::cin >> data2.bookNo >> data2.units_sold >> price;
data2.revenue = data2.units_sold * price;
Вывод суммы двух объектов класса Sales_data
Следующая задача — проверить наличие у транзакций одинакового ISBN. Если это так, вывести их сумму, в противном случае отобразить сообщение об ошибке.
if (data1.bookNo == data2.bookNo) {
unsigned totalCnt = data1.units_sold + data2.units_sold;
double totalRevenue = data1.revenue + data2.revenue;
// вывести: ISBN, общее количество проданных экземпляров,
// общий доход, среднюю цену за книгу
std::cout << data1.bookNo << " " << totalCnt
<< " " << totalRevenue << " ";
if (totalCnt != 0)
std::cout << totalRevenue/totalCnt << std::endl;
else
std::cout << "(no sales)" << std::endl;
return 0; // означает успех
} else { // транзакции не для того же ISBN
std::cerr << "Data must refer to the same ISBN"
<< std::endl;
return -1; // означает неудачу
}
Первый оператор if сравнивает члены bookNo объектов data1 и data2. Если эти члены содержат одинаковый ISBN, выполняется код в фигурных скобках, суммирующий компоненты двух переменных. Поскольку необходимо вывести среднюю цену, сначала вычислим общее количество проданных экземпляров и общий доход, а затем сохраним их в переменных totalCnt и totalRevenue соответственно. Выводим эти значения, а затем проверяем, были ли книги проданы, и если да, то выводим вычисленную среднюю цену за книгу. Если никаких продаж не было, выводим сообщение, обращающее внимание на этот факт.
Упражнения раздела 2.6.2
Упражнение 2.41. Используйте класс Sales_data для перезаписи кода упражнений из разделов 1.5.1, 1.5.2 и 1.6. А также определите свой класс Sales_data в том же файле, что и функция main().
2.6.3. Создание собственных файлов заголовка
Как будет продемонстрировано в разделе 19.7, класс можно определить в функции, однако такие классы ограничены по функциональным возможностям. Поэтому классы обычно не определяют в функциях. При определении класса за пределами функции в каждом файле исходного кода может быть только одно определение этого класса. Кроме того, если класс используется в нескольких разных файлах, определение класса в каждом файле должно быть тем же.
Чтобы гарантировать совпадение определений класса в каждом файле, классы обычно определяют в файлах заголовка. Как правило, классы хранятся в заголовках, имя которых совпадает с именем класса. Например, библиотечный тип string определен в заголовке string. Точно так же, как уже было продемонстрировано, наш класс Sales_data определен в файле заголовка Sales_data.h.
Заголовки (обычно) содержат сущности (такие как определения класса или переменных const и constexpr (см. раздел 2.4), которые могут быть определены в любом файле только однажды. Однако заголовки нередко должны использовать средства из других заголовков. Например, поскольку у класса Sales_data есть член типа string, заголовок Sales_data.h должен включать заголовок string. Как уже упоминалось, программы, использующие класс Sales_data, должны также включать заголовок string, чтобы использовать член bookNo. В результате использующие класс Sales_data программы будут включать заголовок string дважды: один раз непосредственно и один раз как следствие включения заголовка Sales_data.h. Поскольку заголовок мог бы быть включен несколько раз, код необходимо писать так, чтобы обезопасить от многократного включения.
После внесения любых изменений в заголовок необходимо перекомпилировать все использующие его файлы исходного кода, чтобы вступили в силу новые или измененные объявления.
Краткое введение в препроцессор
Наиболее распространенный способ обезопасить заголовок от многократного включения подразумевает использование препроцессора. Препроцессор (preprocessor), унаследованный языком С++ от языка С, является программой, которая запускается перед компилятором и изменяет исходный текст программ. Наши программы уже полагаются на такое средство препроцессора, как директива #include. Когда препроцессор встречает директиву #include, он заменяет ее содержимым указанного заголовка.
Программы С++ используют также препроцессор для защиты заголовка (header guard). Защита заголовка полагается на переменные препроцессора (см. раздел 2.3.2). Переменные препроцессора способны находиться в одном из двух состояний: она либо определена, либо не определена. Директива #define получает имя и определяет его как переменную препроцессора. Есть еще две директивы, способные проверить, определена ли данная переменная препроцессора или нет. Директива #ifdef истинна, если переменная была определена, а директива #ifndef истинна, если переменная не была определена. В случае истинности проверки выполняется все, что расположено после директивы #ifdef или #ifndef и до соответствующей директивы #endif.
Эти средства можно использовать для принятия мер против множественного включения следующим образом:
#ifndef SALES_DATA_H
#define SALES_DATA_H
#include
struct Sales_data {
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
#endif
При первом включении заголовка Sales_data.h директива #ifndef истинна, и препроцессор обработает строки после нее до директивы #endif. В результате переменная препроцессора SALES_DATA_H будет определена, а содержимое заголовка Sales_data.h скопировано в программу. Если впоследствии включить заголовок Sales_data.h в тот же файл, то директива #ifndef окажется ложна и строки между ней и директивой #endif будут проигнорированы.
Имена переменных препроцессора не подчиняются правилам областей видимости языка С++.
Переменные препроцессора, включая имена для защиты заголовка, должны быть уникальными во всей программе. Обычно мы гарантируем уникальность имен защиты заголовка, включая в него имя класса. Чтобы избежать конфликта имен с другими сущностями программы, имена переменных препроцессора обычно пишут полностью в верхнем регистре.
У заголовков должна быть защита, даже если они не включаются в другие заголовки. Защита заголовка проста в написании, и при привычном их определении не нужно размышлять, нужны они или нет.
Упражнения раздела 2.6.3
Упражнение 2.42. Напишите собственную версию заголовка Sales_data.h и используйте его для новой версии упражнения из раздела 2.6.2.
Резюме
Типы — фундаментальная часть всех программ С++.
Каждый тип определяет требования по хранению и операциям, которые можно выполнять с объектами этого типа. Язык предоставляет набор фундаментальных встроенных типов, таких как int и char, которые тесно связаны с их представлением на аппаратных средствах машины. Типы могут быть неконстантными или константными; константный объект следует инициализировать. Будучи однажды инициализированным, значение константного объекта не может быть изменено. Кроме того, можно определить составные типы, такие как указатели или ссылки. Составной тип — это тип, определенный в терминах другого типа.
Язык позволяет определять собственные типы, т.е. классы. Библиотека использует классы, чтобы предоставить набор таких высокоуровневых абстракций, как типы IO и string.
Термины
Адрес (address). Номер байта в памяти, начиная с которого располагается объект.
Арифметический тип (arithmetic type). Встроенные типы, представляющие логические значения, символы, целые числа и числа с плавающей запятой.
Базовый тип (base type). Спецификатор типа, возможно со спецификатором const, который предшествует оператору объявления в объявлении. Базовый тип представляет общий тип, на основании которого строятся операторы объявления в объявлении.
Байт (byte). Наименьший адресуемый блок памяти. На большинстве машин байт составляет 8 битов.
Беззнаковый тип (unsigned). Целочисленный тип данных, переменные которого способны хранить значения больше или равные нулю.
В области видимости (in scope). Имя, которое видимо от текущей области видимости.
Внешняя область видимости (outer scope). Область видимости, включающая другую область видимости.
Внутренняя область видимости (inner scope). Область видимости, вложенная в другую область видимости.
Внутриклассовый инициализатор (in-class initializer). Инициализатор, предоставленный как часть объявления переменной-члена класса. За внутриклассовым инициализатором следует символ =, или он заключается в фигурные скобки.
Временный объект (temporary). Безымянный объект, создаваемый компилятором при вычислении выражения. Временный объект существует до конца вычисления всего выражения, для которого он был создан.
Глобальная область видимости (global scope). Область видимости, внешняя для всех остальных областей видимости.
Директива препроцессора#define. Определяет переменную препроцессора.
Директива препроцессора#endif. Завершает область #ifdef или #ifndef.
Директива препроцессора#ifdef. Выясняет, что данная переменная определена.
Директива препроцессора#ifndef. Выясняет, что данная переменная не определена.
Защита заголовка (header guard). Переменная препроцессора, предназначенная для предотвращения неоднократного подключения содержимого заголовка в один файл исходного кода.
Знаковый тип (signed). Целочисленный тип данных, переменные которого способны хранить отрицательные и положительные числа, включая нуль.
Идентификатор (identifier). Последовательность символов, составляющая имя. Идентификатор зависит от регистра символов.
Инициализация (initialization). Присвоение переменной исходного значения при ее определении. Обычно переменные следует инициализировать.
Инициализация по умолчанию (default initialization). Способ инициализации объектов при отсутствии явной инициализации. Инициализация объектов типа класса определяется классом. Объекты встроенного типа, определенного в глобальной области видимости, инициализируются значением 0, а определенные в локальной области видимости остаются неинициализированными и имеют неопределенное значение.
Интегральный тип (integral type). То же, что и арифметический или целочисленный тип.
Ключевое словоstruct. Используется при определении структуры (класса).
Ключевое словоtypedef. Позволяет определить псевдоним для другого типа. Когда ключевое слово typedef присутствует в объявлении базового типа, определенные в объявлении имена становятся именами типа.
Константная ссылка (const reference). Разговорный термин для ссылки на константный объект.
Константное выражение (constant expression). Выражение, значение которого может быть вычислено во время компиляции.
Константный указатель (const pointer). Указатель со спецификатором const.
Контроль соответствия типов (type checking). Термин, описывающий процесс проверки компилятором соответствия способа использования объекта заявленному для него типу.
Литерал (literal) Значение, такое как число, символ или строка символов. Это значение не может быть изменено. Символьные литералы заключают в одинарные кавычки, а строковые литералы в двойные.
Литералnullptr. Литеральная константа, означающая нулевой указатель.
Локальная область видимости (local scope). Разговорный синоним для области действия блока кода.
Массив (array). Структура данных, содержащая коллекцию неименованных объектов, к которым можно обращаться по индексу. Более подробная информация о массивах приведена в разделе 3.5.
Неинициализированная переменная (uninitialized variable). Переменная, определенная без исходного значения. Обычно попытка доступа к значению неинициализированной переменной приводит к неопределенному поведению.
Неопределенное поведение (undefined behavior). Случай, для которого стандарт языка не определяет значения. Осознанно или неосознанно, но полагаться на неопределенное поведение нельзя. Оно является источником трудно обнаруживаемых ошибок времени выполнения, проблем безопасности и переносимости.
Непечатаемый символ (nonprintable character). Символ, не имеющий видимого представления, например символ возврата на один символ, символ новой строки и т.д.
Нулевой указатель (null pointer). Указатель со значением 0. Нулевой указатель допустим, но не указывает ни на какой объект.
Область видимости (scope). Часть программы, в которой имена имеют смысл. Язык С++ имеет несколько уровней областей видимости.
Глобальная (global) — имена, определенные вне остальных областей видимости.
Класса (class) — имена, определенные классом.
Пространства имен (namespace) — имена, определенные в пространстве имен.
Блока (block) — имена, определенные в блоке операторов, т.е. в паре фигурных скобок.
Области видимости могут быть вложенными. Как только имя объявлено, оно доступно до конца той области видимости, в которой было объявлено.
Объект (object). Область памяти, которая имеет тип. Переменная — это объект, который имеет имя.
Объявление (declaration). Уведомление о существовании переменной, функции или типа, определяемых в другом месте программы. Никакие имена не могут быть использованы, пока они не определены или не объявлены.
Объявление псевдонима (alias declaration). Определяет синоним для другого типа. Объявление в формате using имя = тип объявляет имя как синоним типа тип .
Оператор&. Оператор обращения к адресу. Возвращает адрес объекта, к которому он был применен.
Оператор*. Оператор обращения к значению. Обращение к значению указателя возвращает объект, на который указывает указатель. Присвоение результату оператора обращения к значению присваивает новое значение основному объекту.
Оператор объявления (declarator). Часть объявления, включающая определяемое имя и, необязательно, модификатор типа.
Определение (definition). Резервирует область в памяти для хранения данных переменной и (необязательно) инициализирует ее значение. Никакие имена не могут быть использованы, пока они не определены или не объявлены.
Переменная (variable). Именованный объект или ссылка. В языке С++ переменные должны быть объявлены перед использованием.
Переменнаяconstexpr. Переменная, которая представляет константное выражение.
Функции constexpr рассматриваются в разделе 6.5.2.
Переменная препроцессора (preprocessor variable). Переменная, используемая препроцессором. Препроцессор заменяет каждую переменную препроцессора ее значением прежде, чем программа будет откомпилирована.
Переменная-член (data member). Элемент данных, которые составляют объект. Каждый объект некоего класса обладает собственными экземплярами переменных-членов. Переменные-члены могут быть инициализированы в объявлении класса.
Преобразование (conversion). Процесс, в результате которого значение одного типа преобразуется в значение другого. Преобразования между встроенными типами определены в самом языке.
Препроцессор (preprocessor). Препроцессор — это программа, автоматически запускаемая перед компилятором С++.
Псевдоним типа (type alias). Имя, являющееся синонимом для другого типа. Определяется при помощи ключевого слова typedef или объявления псевдонима.
Раздельная компиляция (separate compilation). Возможность разделить программу на несколько отдельных файлов исходного кода.
Связывание (bind). Соединение имени с указанной сущностью, чтобы использование имени приводило к использованию основной сущности. Например, ссылка — это имя, связанное с объектом.
Слово (word). Специфический для каждой машины размер блока памяти, применяемый при целочисленных вычислениях. Обычно размер слова достаточно велик, чтобы содержать адрес. 32-битовое слово обычно занимает 4 байта.
Составной тип (compound type). Тип, определенный в терминах другого типа.
Спецификаторauto. Спецификатор типа, позволяющий вывести тип переменной из ее инициализатора.
Спецификатор const верхнего уровня (top-level const). Спецификатор const, указывающий, что объект не может быть изменен.
Спецификатор const нижнего уровня (low-level const). Спецификатор const не верхнего уровня. Такие спецификаторы const являются неотъемлемой частью типа и никогда не игнорируются.
Спецификаторconst. Спецификатор типа, определяющий объекты, которые не могут быть изменены. Константные объекты следует инициализировать, поскольку нет никакого способа присвоить им значение после определения.
Спецификаторdecltype. Спецификатор типа, позволяющий вывести тип переменной или выражения.
Спецификатор типа (type specifier). Имя типа.
Списочная инициализация (list initialization). Форма инициализации, подразумевающая использование фигурных скобок для включения одного или нескольких инициализаторов.
Ссылка (reference). Псевдоним другого объекта.
Ссылка на константу (reference to const). Ссылка, неспособная изменить значение объекта, на который она ссылается. Ссылка на константу может быть связана с константным, неконстантным объектом или с результатом выражения.
Типstring. Библиотечный тип, представляющий последовательность символов переменной длины.
Типvoid*. Специальный тип указателя, способного указывать на любой неконстантный тип. Обращение к значению таких указателей невозможно.
Типvoid. Специальный тип без значения и допустимых операций. Нельзя определить переменную типа void.
Указатель (pointer). Объект, способный содержать адрес объекта, следующий адрес за концом объекта или нуль.
Указатель на константу (pointer to const). Указатель, способный содержать адрес константного объекта. Указатель на константу не может использоваться для изменения значения объекта, на который он указывает.
Управляющая последовательность (escape sequence). Альтернативный механизм представления символов. Обычно используется для представления непечатаемых символов, таких как символ новой строки или табуляции. Управляющая последовательность состоит из символа наклонной черты влево, сопровождаемой символом, восьмеричным числом из трех цифр, или символа x, сопровождаемого шестнадцатеричным числом.
Член класса (class member, member). Часть класса.
Глава 3
Типы
string
,
vector
и массивы
Кроме встроенных типов, рассмотренных в главе 2, язык С++ предоставляет богатую библиотеку абстрактных типов данных. Важнейшими библиотечными типами являются тип string, поддерживающий символьные строки переменной длины, и тип vector, определяющий коллекции переменного размера. С типами string и vector связаны типы, известные как итераторы (iterator). Они используются для доступа к символам строк и элементам векторов.
Типы string и vector, определенные в библиотеке, являются абстракциями более простого встроенного типа массива. Эта главы посвящена массивам и введению в библиотечные типы vector и string.
Встроенные типы, рассмотренные в главе 2, определены непосредственно языком С++. Эти типы представляют средства, которые сами по себе присущи большинству компьютеров, такие как числа или символы. Стандартная библиотека определяет множество дополнительных типов, высокоуровневый характер которых аппаратными средствами компьютеров, как правило, не реализуется непосредственно.
В данной главе представлены два важнейших библиотечных типа: string и vector. Тип string — это последовательность символов переменной длины. Тип vector содержит последовательность объектов указанного типа переменной длины. Мы также рассмотрим встроенный тип массива. Как и другие встроенные типы, массивы представляют возможности аппаратных средств. В результате массивы менее удобны в использовании, чем библиотечные типы string и vector.
Однако, прежде чем начать исследование библиотечных типов, рассмотрим механизм, упрощающий доступ к именам, определенным в библиотеке.
3.1. Пространства имен и объявления
using
До сих пор имена из стандартной библиотеки упоминались в программах явно, т.е. перед каждым из них было указано имя пространства имен std. Например, при чтении со стандартного устройства ввода применялась форма записи std::cin. Здесь использован оператор области видимости :: (см. раздел 1.2). Он означает, что имя, указанное в правом операнде оператора, следует искать в области видимости, указанной в левом операнде. Таким образом, код std::cin означает, что используемое имя cin определено в пространстве имен std.
При частом использовании библиотечных имен такая форма записи может оказаться чересчур громоздкой. К счастью, существуют и более простые способы применения членов пространств имен. Самый надежный из них — объявление using (using declaration). Другие способы, позволяющие упростить использование имен из других пространств, рассматриваются в разделе 18.2.2.
Объявление using позволяет использовать имена из другого пространства имен без указания префикса имя_пространства_имен :: . Объявление using имеет следующий формат:
using пространство_имен :: имя ;
После того как объявление using было сделано один раз, к указанному в нем имени можно обращаться без указания пространства имен.
#include
// объявление using; при использовании имени cin теперь
// подразумевается, что оно принадлежит пространству имен std
using std::cin;
int main() {
int i;
cin >> i; // ok: теперь cin - синоним std::cin
cout << i; // ошибка: объявления using нет; здесь нужно указать
// полное имя
std::cout << i; // ok: явно указано применение cout из
// пространства имен std
return 0;
}
Для каждого имени необходимо индивидуальное объявление using
Каждое объявление using применяется только к одному элементу пространства имен. Это позволяет жестко задавать имена, используемые в каждой программе. Например, программу из раздела 1.2 можно переписать следующим образом:
#include
// объявления using для имен из стандартной библиотеки
using std::cin;
using std::cout;
using std::endl;
int main() {
cout << "Enter two numbers:" << endl;
int v1, v2;
cin >> v1 >> v2;
cout << "The sum of " << v1 << " and " << v2
<< " is " << v1 + v2 << endl;
return 0;
}
Объявления using для имен cin, cout и endl означают, что их можно теперь использовать без префикса std::. Напомню, что программы С++ позволяют поместить каждое объявление using в отдельную строку или объединить в одной строке несколько объявлений. Важно не забывать, что для каждого используемого имени необходимо отдельное объявление using, и каждое из них должно завершаться точкой с запятой.
Заголовки не должны содержать объявлений using
Код в заголовках (см. раздел 2.6.3) обычно не должен использовать объявления using. Дело в том, что содержимое заголовка копируется в текст программы, в которую он включен. Если в заголовке есть объявление using, то каждая включающая его программа получает то же объявление using. В результате программа, которая не намеревалась использовать определенное библиотечное имя, может случайно столкнуться с неожиданным конфликтом имен.
Примечание для читателя
Начиная с этого момента подразумевается, что во все примеры включены объявления using для имен из стандартной библиотеки. Таким образом, в тексте и примерах кода далее упоминается cin, а не std::cin.
Кроме того, для экономии места в примерах кода не будем показывать далее объявления using и необходимые директивы #include. В табл. А.1 приложения А приведены имена и соответствующие заголовки стандартной библиотеки, которые использованы в этой книге.
Читатели не должны забывать добавить соответствующие объявления #include и using в свои примеры перед их компиляцией.
Упражнения раздела 3.1
Упражнение 3.1. Перепишите упражнения из разделов 1.4.1 и 2.6.2, используя соответствующие объявления using.
3.2. Библиотечный тип
string
Строка (string) — это последовательность символов переменной длины. Чтобы использовать тип string, необходимо включить в код заголовок string. Поскольку тип string принадлежит библиотеке, он определен в пространстве имен std. Наши примеры подразумевают наличие следующего кода:
#include
using std::string;
В этом разделе описаны наиболее распространенные операции со строками; а дополнительные операции рассматриваются в разделе 9.5.
Кроме определения операций, предоставляемых библиотечными типами, стандарт налагает также требования на эффективность их конструкторов. В результате библиотечные типы оказались весьма эффективны в использовании.
3.2.1. Определение и инициализация строк
Каждый класс определяет, как могут быть инициализированы объекты его типа. Класс может определить много разных способов инициализации объектов своего типа. Каждый из способов отличается либо количеством предоставляемых инициализаторов, либо типами этих инициализаторов. Список наиболее распространенных способов инициализации строк приведен в табл. 3.1, а некоторые из примеров приведены ниже.
string s1; // инициализация по умолчанию; s1 - пустая строка
string s2 = s1; // s2 - копия s1
string s3 = "hiya"; // s3 - копия строкового литерала
string s4(10, 'c'); // s4 - cccccccccc
Инициализация строки по умолчанию (см. раздел 2.2.1) создает пустую строку; т.е. объект класса string без символов. Когда предоставляется строковый литерал (см. раздел 2.1.3), во вновь созданную строку копируются символы этого литерала, исключая завершающий нулевой символ. При предоставлении количества и символа строка содержит указанное количество экземпляров данного символа.
Таблица 3.1. Способы инициализации объекта класса string
string s1 | Инициализация по умолчанию; s1 — пустая строка |
string s2(s1) | s2 — копия s1 |
string s2 = s1 | Эквивалент s2(s1) , s2 — копия s1 |
string s3("value") | s3 — копия строкового литерала, нулевой символ не включен |
string s3 = "value" | Эквивалент s3("value") , s3 — копия строкового литерала |
string s4(n, 'c') | Инициализация переменной s4 символом 'c' в количестве n штук |
Прямая инициализация и инициализация копией
В разделе 2.2.1 упоминалось, что язык С++ поддерживает несколько разных форм инициализации. Давайте на примере класса string начнем изучать, чем эти формы отличаются друг от друга. Когда переменная инициализируется с использованием знака =, компилятор просят скопировать инициализирующий объект в создаваемый объект, т.е. выполнить инициализацию копией (copy initialization). В противном случае без знака = осуществляется прямая инициализация (direct initialization).
Когда имеется одиночный инициализатор, можно использовать и прямую форму, и инициализацию копией. Но при инициализации переменной несколькими значениями, как при инициализации переменной s4 выше, следует использовать прямую форму инициализации.
string s5 = "hiya"; // инициализация копией
string s6("hiya"); // прямая инициализация
string s7(10, 'c'); // прямая инициализация; s7 - сссссссссс
Если необходимо использовать несколько значений, можно применить косвенную форму инициализации копией при явном создании временного объекта для копирования.
string s8 = string(10, 'c'); // инициализация копией; s8 - сссссссссс
Инициализатор строки s8 — string(10, 'c') — создает строку заданного размера, заполненную указанным символьным значением, а затем копирует ее в строку s8. Это эквивалентно следующему коду:
string temp(10, 'c'); // temp - сссссссссс
string s8 = temp; // копировать temp в s8
Хотя используемый для инициализации строки s8 код вполне допустим, он менее читабелен и не имеет никаких преимуществ перед способом, которым была инициализирована переменная s7.
3.2.2. Операции со строками
Наряду с определением способов создания и инициализации объектов класс определяет также операции, которые можно выполнять с объектами класса. Класс может определить обладающие именем операции, такие как функция isbn() класса Sales_item (см. раздел 1.5.2). Класс также может определить то, что означают различные символы операторов, такие как << или +, когда они применяются к объектам класса. Наиболее распространенные операции класса string приведены в табл. 3.2.
Таблица 3.2. Операции класса string
os << s | Выводит строку s в поток вывода os . Возвращает поток os |
is >> s | Читает разделенную пробелами строку s из потока is . Возвращает поток is |
getline(is, s) | Читает строку ввода из потока is в переменную s . Возвращает поток is |
s.empty() | Возвращает значение true , если строка s пуста. В противном случае возвращает значение false |
s.size() | Возвращает количество символов в строке s |
s[n] | Возвращает ссылку на символ в позиции n строки s ; позиции отсчитываются от 0 |
s1 + s2 | Возвращает строку, состоящую из содержимого строк s1 и s2 |
s1 = s2 | Заменяет символы строки s1 копией содержимого строки s2 |
s1 == s2 s1 != s2 | Строки s1 и s2 равны, если содержат одинаковые символы. Регистр символов учитывается |
< , <= , > , >= | Сравнение зависит от регистра и полагается на алфавитный порядок символов |
Чтение и запись строк
Как уже упоминалось в главе 1, для чтения и записи значений встроенных типов, таких как int, double и т.д., используется библиотека iostream. Для чтения и записи строк используются те же операторы ввода и вывода.
// Обратите внимание: перед компиляцией этот код следует дополнить
// директивами #include и объявлениями using
int main() {
string s; // пустая строка
cin >> s; // чтение разделяемой пробелами строки в s
cout << s << endl; // запись s в поток вывода
return 0;
}
Программа начинается с определения пустой строки по имени s. Следующая строка читает данные со стандартного устройства ввода и сохраняет их в переменной s. Оператор ввода строк читает и отбрасывает все предваряющие непечатаемые символы (например, пробелы, символы новой строки и табуляции). Затем он читает значащие символы, пока не встретится следующий непечатаемый символ.
Таким образом, если ввести " Hello World! " (обратите внимание на предваряющие и завершающие пробелы), фактически будет получено значение "Hello" без пробелов.
Подобно операторам ввода и вывода встроенных типов, операторы строк возвращают как результат свой левый операнд. Таким образом, операторы чтения или записи можно объединять в цепочки.
string s1, s2;
cin >> s1 >> s2; // сначала прочитать в переменную s1,
// а затем в переменную s2
cout << s1 << s2 << endl; // отобразить обе строки
Если в этой версии программы осуществить предыдущий ввод, " Hello World! " , выводом будет "HelloWorld!".
Чтение неопределенного количества строк
В разделе 1.4.3 уже рассматривалась программа, читающая неопределенное количество значений типа int. Напишем подобную программу, но читающую строки.
int main() {
string word;
while (cin >> word) // читать до конца файла
cout << word << endl; // отобразить каждое слово с новой строки
return 0;
}
Здесь чтение осуществляется в переменную типа string, а не int. Условие оператора while, напротив, выполняется так же, как в предыдущей программе. Условие проверяет поток после завершения чтения. Если поток допустим, т.е. не встретился символ конца файла или недопустимое значение, выполняется тело цикла while. Оно выводит прочитанное значение на стандартное устройство вывода. Как только встречается конец файла (или недопустимый ввод), цикл while завершается.
Применение функции getline() для чтения целой строки
Иногда игнорировать пробелы во вводе не нужно. В таких случаях вместо оператора >> следует использовать функцию getline(). Функция getline() получает поток ввода и строку. Функция читает предоставленный поток до первого символа новой строки и сохраняет прочитанное, исключая символ новой строки, в своем аргументе типа string. Встретив символ новой строки, даже если это первый символ во вводе, функция getline() прекращает чтение и завершает работу. Если символ новой строки во вводе первый, то возвращается пустая строка.
Подобно оператору ввода, функция getline() возвращает свой аргумент типа istream. В результате функцию getline() можно использовать в условии, как и оператор ввода (см. раздел 1.4.3). Например, предыдущую программу, которая выводила по одному слову в строку, можно переписать так, чтобы она вместо этого выводила всю строку:
int main() {
string line;
// читать строки до конца файла
while (getline(cin, line))
cout << line << endl;
return 0;
}
Поскольку переменная line не будет содержать символа новой строки, его придется вывести отдельно. Для этого, как обычно, используется манипулятор endl, который, кроме перевода строки, сбрасывает буфер вывода.
Символ новой строки, прекращающий работу функции getline(), отбрасывается и в строковой переменной не сохраняется.
Строковые операции size() и empty()
Функция empty() (пусто) делает то, что и ожидается: она возвращает логическое значение true (раздел 2.1), если строка пуста, и значение false — в противном случае. Подобно функции-члену isbn() класса Sales_item (см. раздел 1.5.2), функция empty() является членом класса string. Для вызова этой функции используем точечный оператор, позволяющий указать объект, функцию empty() которого необходимо вызвать.
А теперь пересмотрим предыдущую программу так, чтобы она выводила только непустые строки:
// читать ввод построчно и отбрасывать пустые строки
while (getline(cin, line))
if (!line.empty())
cout << line << endl;
Условие использует оператор логического NOT (оператор !). Он возвращает инверсное значение своего операнда типа bool. В данном случае условие истинно, если строка line не пуста.
Функция size() возвращает длину строки (т.е. количество символов в ней). Давайте используем ее для вывода строк длиной только больше 80 символов.
string line;
// читать ввод построчно и отображать строки длиной более 80 символов
while (getline(cin, line))
if (line.size() > 80)
cout << line << endl;
Тип string::size_type
Вполне логично ожидать, что функция size() возвращает значение типа int, а учитывая сказанное в разделе 2.1.1, вероятней всего, типа unsigned. Но вместо этого функция size() возвращает значение типа string::size_type. Этот тип требует более подробных объяснений.
В классе string (и нескольких других библиотечных типах) определены вспомогательные типы данных. Эти вспомогательные типы позволяют использовать библиотечные типы машинно-независимым способом. Тип size_type — это один из таких вспомогательных типов. Чтобы воспользоваться типом size_type, определенным в классе string, применяется оператор области видимости (оператор ::), указывающий на то, что имя size_type определено в классе string.
Хотя точный размер типа string::size_type неизвестен, можно с уверенностью сказать, что этот беззнаковый тип (см. раздел 2.1.1) достаточно большой, чтобы содержать размер любой строки. Любая переменная, используемая для хранения результата операции size() класса string, должна иметь тип string::size_type.
По общему признанию, довольно утомительно вводить каждый раз тип string::size_type. По новому стандарту можно попросить компилятор самостоятельно применить соответствующий тип при помощи спецификаторов auto или decltype (см. раздел 2.5.2):
auto len = line.size(); // len имеет тип string::size_type
Поскольку функция size() возвращает беззнаковый тип, следует напомнить, что выражения, в которых смешаны знаковые и беззнаковые данные, могут дать непредвиденные результаты (см. раздел 2.1.2). Например, если переменная n типа int содержит отрицательное значение, то выражение s.size() < n почти наверняка истинно. Оно возвращает значение true потому, что отрицательное значение переменной n преобразуется в большое беззнаковое значение.
Проблем преобразования между беззнаковыми и знаковыми типами можно избежать, если не использовать переменные типа int в выражениях, где используется функция size().
Сравнение строк
Класс string определяет несколько операторов для сравнения строк. Эти операторы сравнивают строки посимвольно. Результат сравнения зависит от регистра символов, символы в верхнем и нижнем регистре отличаются.
Операторы равенства (== и !=) проверяют, равны или не равны две строки соответственно. Две строки равны, если у них одинаковая длина и одинаковые символы. Операторы сравнения (<, >, <=, >=) проверяют, меньше ли одна строка другой, больше, меньше или равна, больше или равна другой. Эти операторы используют ту же стратегию, старшинство символов в алфавитном порядке в зависимости от регистра.
1. Если длина у двух строк разная и если каждый символ более короткой строки совпадает с соответствующим символом более длинной, то короткая строка меньше длинной.
2. Если символы в соответствующих позициях двух строк отличаются, то результат сравнения определяется первым отличающимся символом.
Для примера рассмотрим следующие строки:
string str = "Hello";
string phrase = "Hello World";
string slang = "Hiya";
Согласно правилу 1 строка str меньше строки phrase. Согласно правилу 2 строка slang больше, чем строки str и phrase.
Присвоение строк
Как правило, библиотечные типы столь же просты в применении, как и встроенные. Поэтому большинство библиотечных типов поддерживают присвоение. Строки не являются исключением, один объект класса string вполне можно присвоить другому.
string st1(10, 'c'), st2; // st1 - сссссссссс; st2 - пустая строка
st1 = st2; // присвоение: замена содержимого st1 копией st2
// теперь st1 и st2 - пустые строки
Сложение двух строк
Результатом сложения двух строк является новая строка, объединяющая содержимое левого операнда, а затем правого. Таким образом, при применении оператора суммы (оператор +) к строкам результатом будет новая строка, символы которой являются копией символов левого операнда, сопровождаемые символами правого операнда. Составной оператор присвоения (оператор +=) (см. раздел 1.4.1) добавляет правый операнд к строке слева:
string s1 = "hello, ", s2 = "world\n";
string s3 = s1 + s2; // s3 - hello, world\n
s1 += s2; // эквивалентно s1 = s1 + s2
Сложение строк и символьных строковых литералов
Как уже упоминалось в разделе 2.1.2, один тип можно использовать там, где ожидается другой тип, если есть преобразование из данного типа в ожидаемый. Библиотека string позволяет преобразовывать как символьные, так и строковые литералы (см. раздел 2.1.3) в строки. Поскольку эти литералы можно использовать там, где ожидаются строки, предыдущую программу можно переписать следующим образом:
string s1 = "hello", s2 = "world"; // в s1 и s2 нет пунктуации
string s3 = s1 + ", " + s2 + '\n';
Когда объекты класса string смешиваются со строковыми или символьными литералами, то по крайней мере один из операндов каждого оператора + должен иметь тип string.
string s4 = s1 + ", "; // ok: сложение строки и литерала
string s5 = "hello" + ", "; // ошибка: нет строкового операнда
string s6 = s1 + ", " + "world"; // ok: каждый + имеет
// строковый операнд
string s7 = "hello" + ", " + s2; // ошибка: нельзя сложить строковые
// литералы
В инициализации переменных s4 и s5 задействовано только по одному оператору, поэтому достаточно просто проверить его корректность. Инициализация переменной s6 может показаться странной, но работает она аналогично объединенным в цепочку операторам ввода или вывода (см. раздел 1.2). Это эквивалентно следующему коду:
string s6 = (s1 + ", ") + "world";
Часть s1 + ", " выражения возвращает объект класса string, она составляет левый операнд второго оператора +. Это эквивалентно следующему коду:
string tmp = s1 + ", "; // ok: + имеет строковый операнд
s6 = tmp + "world"; // ok: + имеет строковый операнд
С другой стороны, инициализация переменной s7 недопустима, и это становится очевидным, если заключить часть выражения в скобки:
string s7 = ("hello" + ", ") + s2; // ошибка: нельзя сложить строковые
// литералы
Теперь довольно просто заметить, что первая часть выражения суммирует два строковых литерала. Поскольку это невозможно, оператор недопустим.
По историческим причинам и для совместимости с языком С строковые литералы не принадлежат к типу string стандартной библиотеки. При использовании строковых литералов и библиотечного типа string, не следует забывать, что это разные типы.
Упражнения раздела 3.2.2
Упражнение 3.2. Напишите программу, читающую со стандартного устройства ввода по одной строке за раз. Измените программу так, чтобы читать по одному слову за раз.
Упражнение 3.3. Объясните, как символы пробелов обрабатываются в операторе ввода класса string и в функции getline().
Упражнение 3.4. Напишите программу, читающую две строки и сообщающую, равны ли они. В противном случае программа сообщает, которая из них больше. Затем измените программу так, чтобы она сообщала, одинаковая ли у строк длина, а в противном случае — которая из них длиннее.
Упражнение 3.5. Напишите программу, читающую строки со стандартного устройства ввода и суммирующую их в одну большую строку. Отобразите полученную строку. Затем измените программу так, чтобы отделять соседние введенные строки пробелами.
3.2.3. Работа с символами строки
Зачастую приходится работать с индивидуальными символами строки. Например, может понадобиться выяснить, является ли определенный символ пробелом, или изменить регистр символов на нижний, или узнать, присутствует ли некий символ в строке, и т.д.
Одной из частей этих действий является доступ к самим символам строки. Иногда необходима обработка каждого символа, а иногда лишь определенного символа, либо может понадобиться прекратить обработку, как только выполнится некое условие. Кроме того, это наилучший способ справиться со случаями, когда задействуются разные языковые и библиотечные средства.
Другой частью обработки символов является выяснение и (или) изменение их характеристик. Эта часть задачи выполняется набором библиотечных функций, описанных в табл. 3.3. Данные функции определены в заголовке cctype.
Таблица 3.3. Функции cctype
isalnum(с) | Возвращает значение true , если с является буквой или цифрой |
isalpha(с) | Возвращает значение true , если с — буква |
iscntrl(с) | Возвращает значение true , если с — управляющий символ |
isdigit(с) | Возвращает значение true , если с — цифра |
isgraph(с) | Возвращает значение true , если с — не пробел, а печатаемый символ |
islower(с) | Возвращает значение true , если с — символ в нижнем регистре |
isprint(с) | Возвращает значение true , если с — печатаемый символ |
ispunct(с) | Возвращает значение true , если с — знак пунктуации (т.е. символ, который не является управляющим символом, цифрой, символом или печатаемым отступом) |
isspace(с) | Возвращает значение true , если с — символ отступа (т.е. пробел, табуляция, вертикальная табуляция, возврат, новая строка или прогон страницы) |
isupper(с) | Возвращает значение true , если с — символ в верхнем регистре |
isxdigit(с) | Возвращает значение true , если с — шестнадцатеричная цифра |
tolower(с) | Если с — прописная буква, возвращает ее эквивалент в нижнем регистре, в противном случае возвращает символ с неизменным |
toupper(с) | Если с — строчная буква, возвращает ее эквивалент в верхнем регистре, в противном случае возвращает символ с неизменным |
Совет. Используйте версии С++ библиотечных заголовков языка С
Кроме средств, определенных специально для языка С++, его библиотека содержит также библиотеку языка С. Имена заголовков языка С имеют формат имя .h . Версии этих же заголовков языка С++ имеют формат c имя , т.е. суффикс .h удален, а имени предшествует символ с , означающий, что этот заголовок принадлежит библиотеке С.
Следовательно, у заголовка cctype то же содержимое, что и у заголовка ctype.h , но в форме, соответствующей программе С++. В частности, имена, определенные в заголовках с имя, определены также в пространстве имен std , тогда как имена, определенные в заголовках .h , — нет.
Как правило, в программах на языке С++ используют заголовки версии c имя , а не имя .h . Таким образом, имена из стандартной библиотеки будут быстро найдены в пространстве имен std . Использование заголовка .h возлагает на программиста дополнительную заботу по отслеживанию, какие из библиотечных имен унаследованы от языка С, а какие принадлежат языку С++.
Обработка каждого символа, использование серийного оператора for
Если необходимо сделать нечто с каждым символом в строке, то наилучшим подходом является использование оператора, введенного новым стандартом, — серийный оператор for (range for). Этот оператор перебирает элементы данной ему последовательности и выполняет с каждым из них некую операцию. Его синтаксическая форма такова:
for ( объявление : выражение )
оператор
где выражение — это объект типа, который представляет последовательность, а объявление определяет переменную, которая будет использована для доступа к элементам последовательности. На каждой итерации переменная в объявлении инициализируется значением следующего элемента в выражении .
Строка представляет собой последовательность символов, поэтому объект типа string можно использовать как выражение в серийном операторе for. Например, серийный оператор for можно использовать для вывода каждого символа строки в отдельной строке вывода.
string str("some string");
// вывести символы строки str по одному на строку
for (auto с : str) // для каждого символа в строке str
cout << с << endl; // вывести текущий символ и символ новой строки
Цикл for ассоциирует переменную с с переменной str типа string. Управляющая переменная цикла определяется тем же способом, что и любая другая переменная. В данном случае используется спецификатор auto (см. раздел 2.5.2), чтобы позволить компилятору самостоятельно определять тип переменной с, которым в данном случае будет тип char. На каждой итерации следующий символ строки str будет скопирован в переменную с. Таким образом, можно прочитать этот цикл так: "Для каждого символа с в строке str" сделать нечто. Под "нечто" в данном случае подразумевается вывод текущего символа, сопровождаемого символом новой строки.
Рассмотрим более сложный пример и используем серийный оператор for, а также функцию ispunct() для подсчета количества знаков пунктуации в строке:
string s("Hello World!!!");
// punct_cnt имеет тот же тип, что и у возвращаемого значения
// функции s.size(); см. p. 2.5.3
decltype(s.size()) punct_cnt = 0;
// подсчитать количество знаков пунктуации в строке s
for (auto с : s) // для каждого символа в строке s
if (ispunct(c)) // если символ знак пунктуации
++punct_cnt; // увеличить счетчик пунктуаций
cout << punct_cnt
<< " punctuation characters in " << s << endl;
Вывод этой программы таков:
3 punctuation characters in Hello World!!!
Здесь для объявления счетчика punct_cnt используется спецификатор decltype (см. раздел 2.5.3). Его тип совпадает с типом возвращаемого значения функции s.size(), которым является тип string::size_type. Для обработки каждого символа в строке используем серийный оператор for. На сей раз проверяется, является ли каждый символ знаком пунктуации. Если да, то используем оператор инкремента (см. раздел 1.4.1) для добавления единицы к счетчику. Когда серийный оператор for завершает работу, отображается результат.
Использование серийного оператора for для изменения символов в строке
Если необходимо изменить значение символов в строке, переменную цикла следует определить как ссылочный тип (см. раздел 2.3.1). Помните, что ссылка — это только другое имя для данного объекта. При использовании ссылки в качестве управляющей переменной она будет по очереди связана с каждым элементом последовательности. Используя ссылку, можно изменить символ, с которым она связана.
Предположим, что вместо подсчета знаков пунктуации необходимо преобразовать все буквы строки в верхний регистр. Для этого можно использовать библиотечную функцию toupper(), которая возвращает полученный символ в верхнем регистре. Для преобразования всей строки необходимо вызвать функцию toupper() для каждого символа и записать результат в тот же символ:
string s("Hello World!!!");
// преобразовать s в верхний регистр
for (auto &с : s) // для каждого символа в строке s
// (примечание: с - ссылка)
с = toupper(с); // с - ссылка, поэтому присвоение изменяет
// символ в строке s
cout << s << endl;
Вывод этого кода таков:
HELLO WORLD!!!
На каждой итерации переменная с ссылается на следующий символ строки s. При присвоении значения переменной с изменяется соответствующий символ в строке s.
с = toupper(с); // с - ссылка, поэтому присвоение изменяет
// символ в строке s
Таким образом, данное выражение изменяет значение символа, с которым связана переменная с. По завершении этого цикла все символы в строке str будут в верхнем регистре.
Обработка лишь некоторых символов
Серийный оператор for работает хорошо, когда необходимо обработать каждый символ. Но иногда необходим доступ только к одному символу или к некоторому количеству символов на основании некоего условия. Например, можно преобразовать в верхний регистр только первый символ строки или только первое слово в строке.
Существуют два способа доступа к отдельным символам в строке: можно использовать индексирование или итератор. Более подробная информация об итераторах приведена в разделе 3.4 и в главе 9.
Оператор индексирования (оператор []) получает значение типа string::size_type (раздел 3.2.2), обозначающее позицию символа, к которому необходим доступ. Оператор возвращает ссылку на символ в указанной позиции.
Индексация строк начинается с нуля; если строка s содержит по крайней мере два символа, то первым будет символ s[0], вторым — s[1]На самом деле препроцессору. Компилятор получит готовый промежуточный файл, в состав которого войдет содержимое подключенной библиотеки. — Примеч. ред .
, а последним символом является s[s.size() - 1].
Значения, используемые для индексирования строк, не должны быть отрицательными и не должны превосходить размер строки (>= 0 и < size()). Результат использования индекса вне этого диапазона непредсказуем. Непредсказуема также индексация пустой строки.
Значение оператора индексирования называется индексом (index). Индекс может быть любым выражением, возвращающим целочисленное значение. Если у индекса будет знаковый тип, то его значение преобразуется в беззнаковый тип size_type (см. раздел 2.1.2).
Следующий пример использует оператор индексирования для вывода первого символа строки:
if (!s.empty()) // удостоверившись, что символ для вывода есть,
cout << s[0] << endl; // вывести первый символ строки s
Прежде чем обратиться к символу, удостоверимся, что строка s не пуста. При каждом использовании индексирования следует проверять наличие значения в данной области. Если строка s пуста, то значение s[0] неопределенно.
Если строка не константа (см. раздел 2.4), возвращенному оператором индексирования символу можно присвоить новое значение. Например, первый символ можно перевести в верхний регистр следующим образом:
string s("some string");
if (!s.empty()) // удостовериться в наличии символа s[0]
s[0] = toupper(s[0]); // присвоить новое значение первому символу
Вывод этой программы приведен ниже.
Some string
Использование индексирования для перебора
В следующем примере переведем в верхний регистр первое слово строки s:
// обрабатывать символы строки s, пока они не исчерпаются или
// не встретится пробел
for (decltype(s.size()) index = 0;
index != s.size() && !isspace(s[index]); ++index)
s[index] = toupper(s[index]); // преобразовать в верхний регистр
Вывод этой программы таков:
SOME string
Цикл for (см. раздел 1.4.2) использует переменную index для индексирования строки s. Для присвоения переменной index соответствующего типа используется спецификатор decltype. Переменную index инициализируем значением 0, чтобы первая итерация началась с первого символа строки s. На каждой итерации значение переменной index увеличивается, чтобы получить следующий символ строки s. В теле цикла текущий символ переводится в верхний регистр.
В условии цикла for используется новая часть — оператор логического AND (оператор &&). Этот оператор возвращает значение true, если оба операнда истинны, и значение false в противном случае. Важно то, что этот оператор гарантирует обработку своего правого операнда, только если левый операнд истинен. В данном случае это гарантирует, что индексирования строки s не будет, если переменная index находится вне диапазона. Таким образом, часть s[index] выполняется, только если переменная index не равна s.size(). Поскольку инкремент переменной index никогда не превзойдет значения s.size(), переменная index всегда будет меньше s.size().
Внимание! Индексирование не контролируется
При использовании индексирования следует самому позаботиться о том, чтобы индекс оставался в допустимом диапазоне. Индекс должен быть >= 0 и < size() строки. Для упрощения кода, использующего индексирование, в качестве индекса всегда следует использовать переменную типа string::size_type . Поскольку это беззнаковый тип, индекс не может быть меньше нуля. При использовании значения типа size_type в качестве индекса достаточно проверять только то, что значение индекса меньше значения, возвращаемого функцией size() .
#_.jpg Библиотека не обязана проверять и не проверяет значение индекса. Результат использования индекса вне диапазона непредсказуем.
Использование индексирования для произвольного доступа
В предыдущем примере преобразования регистра символов последовательности индекс перемещался на одну позицию за раз. Но можно также вычислить индекс и непосредственно обратиться к выбранному символу. Нет никакой необходимости получать доступ к символам последовательно.
Предположим, например, что имеется число от 0 до 15, которое необходимо представить в шестнадцатеричном виде. Для этого можно использовать строку, инициализированную шестнадцатью шестнадцатеричными цифрами.
const string hexdigits = "0123456789ABCDEF"; // возможные
// шестнадцатеричные цифры
cout << "Enter a series of numbers between 0 and 15"
<< " separated by spaces. Hit ENTER when finished: "
<< endl;
string result; // будет содержать результирующую
// шестнадцатеричную строку
string::size_type n; // содержит введенное число
while (cin >> n)
if (n < hexdigits.size()) // игнорировать недопустимый ввод
result += hexdigits[n]; // выбрать указанную
// шестнадцатеричную цифру
cout << "Your hex number is: " << result << endl;
Если ввести следующие числа:
12 0 5 15 8 15
то результат будет таким:
Your hex number is: C05F8F
Программа начинается с инициализации строки hexdigits, содержащей шестнадцатеричные цифры от 0 до F. Сделаем эту строку константной (см. раздел 2.4), поскольку содержащиеся в ней значения не должны изменяться. Для индексирования строки hexdigits используем в цикле введенное значение n. Значением hexdigits[n] является символ, расположенный в позиции n строки hexdigits. Например, если n равно 15, то результат — F; если 12, то результат — С и т.д. Полученная цифра добавляется к переменной result, которая и выводится, когда весь ввод прочитан.
Всякий раз, когда используется индексирование, следует позаботиться о том, чтобы индекс оставался в диапазоне. В этой программе индекс, n, имеет тип string::size_type, который, как известно, является беззнаковым. В результате значение переменной n гарантированно будет больше или равно 0. Прежде чем использовать переменную n для индексирования строки hexdigits, удостоверимся, что ее значение меньше, чем hexdigits.size().
Упражнения раздела 3.2.3
Упражнение 3.6. Используйте серийный оператор for для замены всех символов строки на X.
Упражнение 3.7. Что будет, если определить управляющую переменную цикла в предыдущем упражнении как имеющую тип char? Предскажите результат, а затем измените программу так, чтобы использовался тип char, и убедитесь в своей правоте.
Упражнение 3.8. Перепишите программу первого упражнения, сначала используя оператор while, а затем традиционный цикл for. Какой из этих трех подходов вы предпочтете и почему?
Упражнение 3.9. Что делает следующая программа? Действительно ли она корректна? Если нет, то почему?
string s;
cout << s[0] << endl;
Упражнение 3.10. Напишите программу, которая читает строку символов, включающую знаки пунктуации, и выведите ее, но уже без знаков пунктуации.
Упражнение 3.11. Допустим ли следующий серийный оператор for? Если да, то каков тип переменной с?
const string s = "Keep out!";
for (auto &c : s) {/*...*/}
3.3. Библиотечный тип
vector
Вектор (vector) — это коллекция объектов одинакового типа, каждому из которых присвоен целочисленный индекс, предоставляющий доступ к этому объекту. Вектор — это контейнер (container), поскольку он "содержит" другие объекты. Более подробная информация о контейнерах приведена в части II.
Чтобы использовать вектор, необходимо включить соответствующий заголовок. В примерах подразумевается также, что включено соответствующее объявление using.
#include
using std::vector;
Типvector — это шаблон класса (class template). Язык С++ поддерживают шаблоны и классов, и функций. Написание шаблона требует довольно глубокого понимания языка С++. До главы 16 мы даже не будем рассматривать создание собственных шаблонов! К счастью, чтобы использовать шаблоны, вовсе не обязательно уметь их создавать.
Шаблоны сами по себе не являются ни функциями, ни классами. Их можно считать инструкцией для компилятора по созданию классов или функций. Процесс, используемый компилятором для создания классов или функций по шаблону, называется созданием экземпляра (instantiation) шаблона. При использовании шаблона необходимо указать, экземпляр какого класса или функции должен создать компилятор.
Для создания экземпляра шаблона класса следует указать дополнительную информацию, характер которой зависит от шаблона. Эта информация всегда задается одинаково: в угловых скобках после имени шаблона.
В случае вектора предоставляемой дополнительной информацией является тип объектов, которые он должен содержать:
vector
vector
vector
В этом примере компилятор создает три разных экземпляра шаблона vector: vector
vector — это шаблон, а не класс. Классам, созданным по шаблону vector, следует указать тип хранимого элемента, например vector
Можно определить векторы для содержания объектов практически любого типа. Поскольку ссылки не объекты (см. раздел 2.3.1), не может быть вектора ссылок. Однако векторы большинства других (не ссылочных) встроенных типов и типов классов вполне могут существовать. В частности, может быть вектор, элементами которого являются другие векторы.
Следует заметить, что прежние версии языка С++ использовали несколько иной синтаксис определения вектора, элементы которого сами являлись экземплярами шаблона vector (или другого типа шаблона). Прежде необходимо было ставить пробел между закрывающей угловой скобкой внешней части vector и типом его элемента: т.е. vector
Некоторые компиляторы могут потребовать объявления вектора векторов в старом стиле, например vector
3.3.1. Определение и инициализация векторов
Подобно любому типу класса, шаблон vector контролирует способ определения и инициализации векторов. Наиболее распространенные способы определения векторов приведены в табл. 3.4.
Инициализация вектора по умолчанию (см. раздел 2.2.1) позволяет создать пустой вектор определенного типа:
vector
// у svec нет элементов
Могло бы показаться, что пустой вектор бесполезен. Однако, как будет продемонстрировано вскоре, элементы в вектор можно без проблем добавлять и во время выполнения. В действительности наиболее распространенный способ использования векторов подразумевает определение первоначально пустого вектора, в который элементы добавляются по мере необходимости во время выполнения.
Таблица 3.4. Способы инициализации векторов
vector<T> v1 | Вектор, содержащий объекты типа T . Стандартный конструктор v1 пуст |
vector<T> v2(v1) | Вектор v2 — копия всех элементов вектора v1 |
vector<T> v2 = v1 | Эквивалент v2(v1) , v2 — копия элементов вектора v1 |
vector<T> v3(n, val) | Вектор v3 содержит n элементов со значением val |
vector<T> v4(n) | Вектор v4 содержит n экземпляров объекта типа T , инициализированного значением по умолчанию |
vector<T> v5{a,b,с ...} | Вектор v5 содержит столько элементов, сколько предоставлено инициализаторов; элементы инициализируются соответствующими инициализаторами |
vector<T> v5 = {a,b,с ... } | Эквивалент v5{a,b,c ... } |
При определении вектора для его элементов можно также предоставить исходное значение (или значения). Например, можно скопировать элементы из другого вектора. При копировании векторов каждый элемент нового вектора будет копией соответствующего элемента исходного. Оба вектора должны иметь тот же тип:
vector
// присвоить ivec несколько значений
vector
vector
vector
// а не целые числа
Списочная инициализация вектора
Согласно новому стандарту, еще одним способом предоставления значений элементам вектора является списочная инициализация (см. раздел 2.2.1), т.е. заключенный в фигурные скобки список любого количества начальных значений элементов:
vector
В результате у вектора будет три элемента: первый со значением "а", второй — "an", последний — "the".
Как уже упоминалось, язык С++ предоставляет несколько форм инициализации (см. раздел 2.2.1). Во многих, но не во всех случаях эти формы инициализации можно использовать взаимозаменяемо. На настоящий момент приводились примеры двух форм инициализации: инициализация копией (с использованием знака =) (см. раздел 3.2.1), когда предоставляется только один инициализатор; и внутриклассовая инициализация (см. раздел 2.6.1). Третий способ подразумевает предоставление списка значений элементов, заключенных в фигурные скобки (списочная инициализация). Нельзя предоставить список инициализаторов, используя круглые скобки.
vector
vector
Создание определенного количества элементов
Вектор можно также инициализировать набором из определенного количества элементов, обладающих указанным значением. Счетчик задает количество элементов, а за ним следует исходное значение для каждого из этих элементов.
vector
// которых инициализирован значением -1
vector
// значением "hi!"
Инициализация значения
Иногда инициализирующее значение можно пропустить и указать только размер. В этом случае произойдет инициализация значения (value initialization), т.е. библиотека создаст инициализатор элемента сама. Это созданное библиотекой значение используется для инициализации каждого элемента в контейнере. Значение инициализатора элемента вектора зависит от типа его элементов.
Если вектор хранит элементы встроенного типа, такие как int, то инициализатором элемента будет значение 0. Если элементы имеют тип класса, такой как string, то инициализатором элемента будет его значение по умолчанию.
vector
// значением 0
vector
// пустой строкой
Эта форма инициализации имеет два ограничения. Первое — некоторые классы всегда требуют явного предоставления инициализатора (см. раздел 2.2.1). Если вектор содержит объекты, тип которых не имеет значения по умолчанию, то начальное значение элемента следует предоставить самому; невозможно создать векторы таких типов, предоставив только размер.
Второе ограничение заключается в том, что при предоставлении количества элементов без исходного значения необходимо использовать прямую инициализацию (direct initialization):
vector
Здесь число 10 используется для указания на то, как создать вектор, — необходимо, чтобы он обладал десятью элементами с инициализированными значениями. Число 10 не "копируется" в вектор. Следовательно, нельзя использовать форму инициализации копией. Более подробная информация об этом ограничении приведена в разделе 7.5.4.
#magnify.png Списочный инициализатор или количество элементов
В некоторых случаях смысл инициализации зависит от того, используются ли при передаче инициализаторов фигурные скобки или круглые. Например, при инициализации вектора vector
vector
vector
vector
vector
Круглые скобки позволяют сообщить, что предоставленные значения должны использоваться для создания объекта. Таким образом, векторы v1 и v3 используют свои инициализаторы для определения размера вектора, а также размера и значения его элементов соответственно.
Использование фигурных скобок, {...}, означает попытку списочной инициализации. Таким образом, если класс способен использовать значения в фигурных скобках как список инициализаторов элементов, то он так и сделает. Если это невозможно, то следует рассмотреть другие способы инициализации объектов. Значения, предоставленные при инициализации векторов v2 и v4, рассматриваются как значения элементов. Это списочная инициализация объектов; у полученных векторов будет один и два элемента соответственно.
С другой стороны, если используются фигурные скобки и нет никакой возможности использовать инициализаторы для списочной инициализации объектов, то эти значения будут использоваться для создания объектов. Например, для списочной инициализации вектора строк следует предоставлять значения, которые можно рассматривать как строки. В данном случае нет никаких сомнений, осуществляется ли списочная инициализация элементов или создание вектора указанного размера.
vector
// один элемент
vector
// строкового литерала
vector
// значением по умолчанию
vector
// значением "hi"
Хотя фигурные скобки использованы во всех этих определениях, кроме одного, только вектор v5 имеет списочную инициализацию. Для списочной инициализации вектора значения в фигурных скобках должны соответствовать типу элемента. Нельзя использовать объект типа int для инициализации строки, поэтому инициализаторы векторов v1 и v8 не могут быть инициализаторами элементов. Если списочная инициализация невозможна, компилятор ищет другие способы инициализации объектов.
Упражнения раздела 3.3.1
Упражнение 3.12. Есть ли ошибки в следующих определениях векторов?
Объясните, что делают допустимые определения. Объясните, почему некорректны недопустимые определения.
(a) vector
(b) vector
(c) vector
Упражнение 3.13. Сколько элементов находится в каждом из следующих векторов? Каковы значения этих элементов?
(a) vector
(с) vector
(e) vector
(g) vector
3.3.2. Добавление элементов в вектор
Прямая инициализация элементов вектора осуществима только при небольшом количестве исходных значений, при копировании другого вектора и при инициализации всех элементов тем же значением. Но обычно при создании вектора неизвестно ни количество его элементов, ни их значения. Но даже если все значения известны, то определение большого количества разных начальных значений может оказаться очень громоздким, чтобы располагать его в месте создания вектора.
Если необходим вектор со значениями от 0 до 9, то можно легко использовать списочную инициализацию. Но что если необходимы элементы от 0 до 99 или от 0 до 999? Списочная инициализация была бы слишком громоздкой. В таких случаях лучше создать пустой вектор и использовать его функцию-член push_back(), чтобы добавить элементы во время выполнения. Функция push_back() вставляет переданное ей значение в вектор как новый последний элемент. Рассмотрим пример.
vector
for (int i = 0; i != 100; ++i)
v2.push_back(i); // добавить последовательность целых чисел в v2
// по завершении цикла v2 имеет 100 элементов со значениями от 0 до 99
Хотя заранее известно, что будет 100 элементов, вектор v2 определяется как пустой. Каждая итерация добавляет следующее по порядку целое число в вектор v2 как новый элемент.
Тот же подход используется, если необходимо создать вектор, количество элементов которого до времени выполнения неизвестно. Например, в вектор можно читать введенные пользователем значения.
// читать слова со стандартного устройства ввода и сохранять их
// в векторе как элементы
string word;
vector
while (cin >> word) {
text.push_back(word); // добавить слово в текст
}
И снова все начинается с пустого вектора. На сей раз, неизвестное количество значений читается и сохраняется в векторе строк text.
Ключевая концепция. Рост вектора эффективен
Стандарт требует, чтобы реализация шаблона vector обеспечивала эффективное добавление элементов во время выполнения. Поскольку рост вектора эффективен, определение вектора сразу необходимого размера зачастую является ненужным и может даже привести к потере производительности. Исключением является случай, когда все элементы нуждаются в одинаковом значении. При разных значениях элементов обычно эффективней определить пустой вектор и добавлять элементы во время выполнения, по мере того, как значения становятся известны. Кроме того, как будет продемонстрировано в разделе 9.4, шаблон vector предоставляет возможности для дальнейшего увеличения производительности при добавлении элементов во время выполнения.
Начало с пустого вектора и добавление элементов во время выполнения кардинально отличается от использования встроенных массивов в языке С и других языках. В частности, если вы знакомы с языком С или Java, то, вероятно, полагаете, что лучше определить вектор в его ожидаемом размере, но фактически имеет место обратное.
Последствия возможности добавления элементов в вектор
Тот факт, что добавление элементов в вектор весьма эффективно, существенно упрощает многие задачи программирования. Но эта простота налагает новые обязательства на наши программы: необходимо гарантировать корректность всех циклов, даже если цикл изменяет размер вектора.
Другое последствие динамического характера векторов станет яснее, когда мы узнаем больше об их использовании. Но есть одно последствие, на которое стоит обратить внимание уже сейчас: по причинам, изложенным в разделе 5.4.3, нельзя использовать серийный оператор for, если тело цикла добавляет элементы в вектор.
Тело серийного оператора for не должно изменять размер перебираемой последовательности.
Упражнения раздела 3.3.2
Упражнение 3.14. Напишите программу, читающую последовательность целых чисел из потока cin и сохраняющую их в векторе.
Упражнение 3.15. Повторите предыдущую программу, но на сей раз читайте строки.
3.3.3. Другие операции с векторами
Кроме функции push_back(), шаблон vector предоставляет еще несколько операций, большинство из которых подобно соответствующим операциям класса string. Наиболее важные из них приведены в табл. 3.5.
Таблица 3.5. Операции с векторами
v.empty() | Возвращает значение true , если вектор v пуст. В противном случае возвращает значение false |
v.size() | Возвращает количество элементов вектора v |
v.push_back(t) | Добавляет элемент со значением t в конец вектора v |
v[n] | Возвращает ссылку на элемент в позиции n вектора v |
v1 = v2 | Заменяет элементы вектора v1 копией элементов вектора v2 |
v1 = {a,b,с ... } | Заменяет элементы вектора v1 копией элементов из разделяемого запятыми списка |
v1 == v2 v1 != v2 | Векторы v1 и v2 равны, если они содержат одинаковые элементы в тех же позициях |
< , <= , > , >= | Имеют обычное значение и полагаются на алфавитный порядок |
Доступ к элементам вектора осуществляется таким же способом, как и к символам строки: по их позиции в векторе. Например, для обработки все элементов вектора можно использовать серийный оператор for (раздел 3.2.3).
vector
for (auto &i : v) // для каждого элемента вектора v
// (обратите внимание: i - ссылка)
i *= i; // квадрат значения элемента
for (auto i : v) // для каждого элемента вектора v
cout << i << " "; // вывод элемента
cout << endl;
В первом цикле управляющая переменная i определяется как ссылка, чтобы использовать ее для присвоения новых значений элементам вектора v. Используя спецификатор auto, позволим вывести ее тип автоматически. Этот цикл использует новую форму составного оператора присвоения (раздел 1.4.1). Как известно, оператор += добавляет правый операнд к левому и сохраняет результат в левом операнде. Оператор *= ведет себя точно так же, но перемножает левый и правый операнды, сохраняя результат в левом операнде. Второй серийный оператор for отображает каждый элемент.
Функции-члены empty() и size() вектора ведут себя так же, как и соответствующие функции класса string (раздел 3.2.2): функция empty() возвращает логическое значение, указывающее, содержит ли вектор какие-нибудь элементы, а функция size() возвращает их количество. Функция-член size() возвращает значение типа size_type, определенное соответствующим типом шаблона vector.
Чтобы использовать тип size_type, необходимо указать тип, для которого он определен. Для типа vector всегда необходимо указывать тип хранимого элемента (раздел 3.3).
vector
vector::size_type // ошибка
Операторы равенства и сравнения вектора ведут себя как соответствующие операторы класса string (раздел 3.2.2). Два вектора равны, если у них одинаковое количество элементов и значения соответствующих элементов совпадают. Операторы сравнения полагаются на алфавитный порядок: если у векторов разные размеры, но соответствующие элементы равны, то вектор с меньшим количеством элементов меньше вектора с большим количеством элементов. Если у элементов векторов разные значения, то их отношения определяются по первым отличающимся элементам.
Сравнить два вектора можно только в том случае, если возможно сравнить элементы этих векторов. Некоторые классы, такие как string, определяют смысл операторов равенства и сравнения. Другие, такие как класс Sales_item, этого не делают. Операции, поддерживаемые классом Sales_item, перечислены в разделе 1.5.1. Они не включают ни операторов равенства, ни сравнения. В результате нельзя сравнить два вектора объектов класса Sales_item.
Вычисление индекса вектора
Используя оператор индексирования (раздел 3.2.3), можно выбрать указанный элемент. Подобно строкам, индексирование вектора начинаются с 0; индекс имеет тип size_type соответствующего типа; и если вектор не константен, то в возвращенный оператором индексирования элемент можно осуществить запись. Кроме того, как было продемонстрировано в разделе 3.2.3, можно вычислить индекс и непосредственно обратиться к элементу в данной позиции.
Предположим, имеется набор оценок степеней в диапазоне от 0 до 100. Необходимо рассчитать, сколько оценок попадает в кластер по 10. Между нулем и 100 возможна 101 оценка. Эти оценки могут быть представлены 11 кластерами: 10 кластеров по 10 оценок каждый плюс один кластер для наивысшей оценки 100. Первый кластер подсчитывает оценки от 0 до 9, второй — от 10 до 19 и т.д. Заключительный кластер подсчитывает количество оценок 100.
Таким образом, если введены следующие оценки:
42 65 95 100 39 67 95 76 88 76 83 92 76 93
результат их кластеризации должен быть таким:
0 0 0 1 1 0 2 3 2 4 1
Он означает, что не было никаких оценок ниже 30, одна оценка в 30-х, одна в 40-х, ни одной в 50-х, две в 60-х, три в 70-х, две в 80-х, четыре в 90-х и одна оценка 100.
Используем для содержания счетчиков каждого кластера вектор с 11 элементами. Индекс кластера для данной оценки можно определить делением этой оценки на 10. При делении двух целых чисел получается целое число, дробная часть которого усекается. Например, 42/10=4, 65/10=6, а 100/10=10.
Как только индекс кластера будет вычислен, его можно использовать для индексирования вектора и доступа к счетчику, значение которого необходимо увеличить.
// подсчет количества оценок в кластере по десять: 0--9,
// 10--19, ... 90--99, 100
vector
unsigned grade;
while (cin >> grade) { // читать оценки
if (grade <= 100) // обрабатывать только допустимые оценки
++scores[grade/10]; // приращение счетчика текущего кластера
Код начинается с определения вектора для хранения счетчиков кластеров. В данном случае все элементы должны иметь одинаковое значение, поэтому резервируем 11 элементов, каждый из которых инициализируем значением 0. Условие цикла while читает оценки. В цикле проверяется допустимость значения прочитанной оценки (т.е. оно меньше или равно 100). Если оценка допустима, то увеличиваем соответствующий счетчик.
Оператор, осуществляющий приращение, является хорошим примером краткости кода С++:
++scores[grade/10]; // приращение счетчика текущего кластера
Это выражение эквивалентно следующему:
auto ind = grade/10; // получить индекс ячейки
scores[ind] = scores[ind] + 1; // приращение счетчика
Индекс ячейки вычисляется делением значения переменной grade на 10. Полученный результат используется для индексирования вектора scores, что обеспечивает доступ к соответствующему счетчику для этой оценки. Увеличение значения этого элемента означает принадлежность текущей оценки данному диапазону.
Как уже упоминалось, при использовании индексирования следует позаботиться о том, чтобы индексы оставались в диапазоне допустимых значений (см. раздел 3.2.3). В этой программе проверка допустимости подразумевает принадлежность оценки к диапазону 0-100. Таким образом, можно использовать индексы от 0 до 10. Они расположены в пределах от 0 до scores.size() - 1.
Индексация не добавляет элементов
Новички в С++ иногда полагают, что индексирование вектора позволяет добавлять в него элементы, но это не так. Следующий код намеревается добавить десять элементов в вектор ivec:
vector
for (decltype(ivec.size()) ix = 0; ix != 10; ++ix)
ivec[ix] = ix; // катастрофа: ivec не имеет элементов
Причина ошибки — вектор ivec пуст; в нем нет никаких элементов для индексирования! Как уже упоминалось, правильный цикл использовал бы функцию push_back():
for (decltype(ivec.size()) ix = 0; ix != 10; ++ix)
ivec.push_back(ix); // ok: добавляет новый элемент со значением ix
Оператор индексирования вектора (и строки) лишь выбирает существующий элемент; он не может добавить новый элемент.
Внимание! Индексировать можно лишь существующие элементы!
Очень важно понять, что оператор индексирования ( [] ) можно использовать для доступа только к фактически существующим элементам. Рассмотрим пример.
vector<int> ivec; // пустой вектор
cout << ivec[0]; // ошибка: ivec не имеет элементов!
vector<int> ivec2(10); // вектор из 10 элементов
cout << ivec2[10]; // ошибка: ivec2 имеет элементы 0...9
Попытка обращения к несуществующему элементу является серьезной ошибкой, которую вряд ли обнаружит компилятор. В результате будет получено случайное значение.
Попытка индексирования несуществующих элементов, к сожалению, является весьма распространенной и грубой ошибкой программирования. Так называемая ошибка переполнения буфера (buffer overflow) — результат индексирования несуществующих элементов. Такие ошибки являются наиболее распространенной причиной проблем защиты приложений.
Наилучший способ гарантировать невыход индекса из диапазона — это избежать индексации вообще. Для этого везде, где только возможно, следует использовать серийный оператор for.
Упражнения раздела 3.3.3
Упражнение 3.16. Напишите программу, выводящую размер и содержимое вектора из упражнения 3.13. Проверьте правильность своих ответов на это упражнение. При неправильных ответах повторно изучите раздел 3.3.1.
Упражнение 3.17. Прочитайте последовательность слов из потока cin и сохраните их в векторе. Прочитав все слова, обработайте вектор и переведите символы каждого слова в верхний регистр. Отобразите преобразованные элементы по восемь слов на строку.
Упражнение 3.18. Корректна ли следующая программа? Если нет, то как ее исправить?
vector
ivec[0] = 42;
Упражнение 3.19. Укажите три способа определения вектора и заполнения его десятью элементами со значением 42. Укажите, есть ли предпочтительный способ для этого и почему.
Упражнение 3.20. Прочитайте набор целых чисел в вектор. Отобразите сумму каждой пары соседних элементов. Измените программу так, чтобы она отображала сумму первого и последнего элементов, затем сумму второго и предпоследнего и т.д.
3.4. Знакомство с итераторами
Хотя для доступа к символам строки или элементам вектора можно использовать индексирование, для этого существует и более общий механизм — итераторы (iterator). Как будет продемонстрировано в части II, кроме векторов библиотека предоставляет несколько других видов контейнеров. У всех библиотечных контейнеров есть итераторы, но только некоторые из них поддерживают оператор индексирования. С технической точки зрения тип string не является контейнерным, но он поддерживает большинство контейнерных операций. Как уже упоминалось, и строки, и векторы предоставляют оператор индексирования. У них также есть итераторы.
Как и указатели (см. раздел 2.3.2), итераторы обеспечивают косвенный доступ к объекту. В случае итератора этим объектом является элемент в контейнере или символ в строке. Итератор позволяет выбрать элемент, а также поддерживает операции перемещения с одного элемента на другой. Подобно указателям, итератор может быть допустим или недопустим. Допустимый итератор указывает либо на элемент, либо на позицию за последним элементом в контейнере. Все другие значения итератора недопустимы.
3.4.1. Использование итераторов
В отличие от указателей, для получения итератора не нужно использовать оператор обращения к адресу. Для этого обладающие итераторами типы имеют члены, возвращающие эти итераторы. В частности, они обладают функциями-членами begin() и end(). Функция-член begin() возвращает итератор, который обозначает первый элемент (или первый символ), если он есть.
// типы b и е определяют компилятор; см. раздел 2.5.2
// b обозначает первый элемент контейнера v, а е - элемент
// после последнего
auto b = v.begin(), е = v.end();
// b и е имеют одинаковый тип
Итератор, возвращенный функцией end(), указывает на следующую позицию за концом контейнера (или строки). Этот итератор обозначает несуществующий элемент за концом контейнера. Он используется как индикатор, означающий, что обработаны все элементы. Итератор, возвращенный функцией end(), называют итератором после конца (off-the-end iterator), или сокращенно итератором end. Если контейнер пуст, функция begin() возвращает тот же итератор, что и функция end().
Если контейнер пуст, возвращаемые функциями begin() и end() итераторы совпадают и, оба являются итератором после конца.
Обычно точный тип, который имеет итератор, неизвестен (да и не нужен). В этом примере при определении итераторов b и е использовался спецификатор auto (см. раздел 2.5.2). В результате тип этих переменных будет совпадать с возвращаемыми функциями-членами begin() и end() соответственно. Не будем пока распространяться об этих типах.
Операции с итераторами
Итераторы поддерживают лишь несколько операций, которые перечислены в табл. 3.6. Два допустимых итератора можно сравнить при помощи операторов == и !=. Итераторы равны, если они указывают на тот же элемент или если оба они указывают на позицию после конца того же контейнера. В противном случае они не равны.
Таблица 3.6. Стандартные операции с итераторами контейнера
*iter | Возвращает ссылку на элемент, обозначенный итератором iter |
iter->mem | Обращение к значению итератора iter и выборка члена mem основного элемента. Эквивалент (*iter).mem |
++iter | Инкремент итератора iter для обращения к следующему элементу контейнера |
--iter | Декремент итератора iter для обращения к предыдущему элементу контейнера |
iter1 == iter2 iter1 != iter2 | Сравнивает два итератора на равенство (неравенство). Два итератора равны, если они указывают на тот же элемент или на следующий элемент после конца того же контейнера |
Подобно указателям, к значению итератора можно обратиться, чтобы получить элемент, на который он ссылается. Кроме того, подобно указателям, можно обратиться к значению только допустимого итератора, который обозначает некий элемент (см. раздел 2.3.2). Результат обращения к значению недопустимого итератора или итератора после конца непредсказуем.
Перепишем программу из раздела 3.2.3, преобразующую строчные символы строки в прописные, с использованием итератора вместо индексирования:
string s("some string");
if (s.begin() != s.end()) { // удостовериться, что строка s не пуста
auto it = s.begin(); // it указывает на первый символ строки s
*it = toupper(*it); // текущий символ в верхний регистр
}
Как и в первоначальной программе, сначала удостоверимся, что строка s не пуста. В данном случае для этого сравниваются итераторы, возвращенные функциями begin() и end(). Эти итераторы равны, если строка пуста. Если они не равны, то в строке s есть по крайней мере один символ.
В теле оператора if функция begin() возвращает итератор на первый символ, который присваивается переменной it. Обращение к значению этого итератора и передача его функции toupper() позволяет перевести данный символ в верхний регистр. Кроме того, обращение к значению итератора it слева от оператора присвоения позволяет присвоить символ, возвращенный функцией toupper(), первому символу строки s. Как и в первоначальной программе, вывод будет таким:
Some string
Перемещение итератора с одного элемента на другой
Итераторы используют оператор инкремента (оператор ++) (см. раздел 1.4.1) для перемещения с одного элемента на следующий. Операция приращения итератора логически подобна приращению целого числа. В случае целых чисел результатом будет целочисленное значение на единицу больше 1. В случае итераторов результатом будет перемещение итератора на одну позицию.
Поскольку итератор, возвращенный функцией end(), не указывает на элемент, он не допускает ни приращения, ни обращения к значению.
Перепишем программу, изменяющую регистр первого слова в строке, с использованием итератора.
// обрабатывать символы, пока они не исчерпаются,
// или не встретится пробел
for (auto it = s.begin(); it != s.end() && !isspace(*it); ++it)
*it = toupper(*it); // преобразовать в верхний регистр
Этот цикл, подобно таковому в разделе 3.2.3, перебирает символы строки s, останавливаясь, когда встречается пробел. Но данный цикл использует для этого итератор, а не индексирование.
Цикл начинается с инициализации итератора it результатом вызова функции s.begin(), чтобы он указывал на первый символ строки s (если он есть). Условие проверяет, не достиг ли итератор it конца строки (s.end()). Если это не так, то проверяется следующее условие, где обращение к значению итератора it, возвращающее текущий символ, передается функции isspace(), чтобы выяснить, не пробел ли это. В конце каждой итерации выполняется оператор ++it, чтобы переместить итератор на следующий символ строки s.
У этого цикла то же тело, что и у последнего оператора if предыдущей программы. Обращение к значению итератора it используется и для передачи текущего символа функции toupper(), и для присвоения полученного результата символу, на который указывает итератор it.
Ключевая концепция. Обобщенное программирование
Программисты, перешедшие на язык С++ с языка С или Java, могли бы быть удивлены тем, что в данном цикле for был использован оператор != , а не < . Программисты С++ используют оператор != исключительно по привычке. По этой же причине они используют итераторы, а не индексирование: этот стиль программирования одинаково хорошо применим к контейнерам различных видов, предоставляемых библиотекой.
Как уже упоминалось, только у некоторых библиотечных типов, vector и string , есть оператор индексирования. Тем не менее у всех библиотечных контейнеров есть итераторы, для которых определены операторы == и != . Однако большинство их итераторов не имеют оператора < . При обычном использовании итераторов и оператора != можно не заботиться о точном типе обрабатываемого контейнера.
Типы итераторов
Подобно тому, как не всегда известен точный тип size_type элемента вектора или строки (см. раздел 3.2.2), мы обычно не знаем (да и не обязаны знать) точный тип итератора. Как и в случае с типом size_type, библиотечные типы, у которых есть итераторы, определяют типы по имени iterator и const_iterator, которые представляют фактические типы итераторов.
vector
// в элементы вектора vector
string::iterator it2; // it2 позволяет читать и записывать
// символы в строку
vector
// записывать элементы
string::const_iterator it4; // it4 позволяет читать, но не
// записывать символы
Тип const_iterator ведет себя как константный указатель (см. раздел 2.4.2). Как и константный указатель, тип const_iterator позволяет читать, но не писать в элемент, на который он указывает; объект типа iterator позволяет и читать, и записывать. Если вектор или строка являются константой, можно использовать итератор только типа const_iterator. Если вектор или строка на являются константой, можно использовать итератор и типа iterator, и типа const_iterator.
Терминология. Итераторы и типы итераторов
Термин итератор (iterator) используется для трех разных сущностей. Речь могла бы идти о концепции итератора, или о типе iterator , определенном классом контейнера, или об объекте итератора.
Следует уяснить, что существует целый набор типов, связанных концептуально. Тип относится к итераторам, если он поддерживает общепринятый набор функций. Эти функции позволяют обращаться к элементу в контейнере и переходить с одного элемента на другой.
Каждый класс контейнера определяет тип по имени iterator , который обеспечивает действия концептуального итератора.
Функции begin() и end()
Тип, возвращаемый функциями begin() и end(), зависит от константности объекта, для которого они были вызваны. Если объект является константой, то функции begin() и end() возвращают итератор типа const_iterator; если объект не константа, они возвращают итератор типа iterator.
vector
const vector
auto it1 = v.begin(); // it1 имеет тип vector
auto it2 = cv.begin(); // it2 имеет тип vector
Зачастую это стандартное поведение желательно изменить. По причинам, рассматриваемым в разделе 6.2.3, обычно лучше использовать константный тип (такой как const_iterator), когда необходимо только читать, но не записывать в объект. Чтобы позволить специально задать тип const_iterator, новый стандарт вводит две новые функции, cbegin() и cend():
auto it3 = v.cbegin(); // it3 имеет тип vector
Подобно функциям-членам begin() и end(), эти функции-члены возвращают итераторы на первый и следующий после последнего элементы контейнера. Но независимо от того, является ли вектор (или строка) константой, они возвращают итератор типа const_iterator.
Объединение обращения к значению и доступа к члену
При обращении к значению итератора получается объект, на который указывает итератор. Если этот объект имеет тип класса, то может понадобиться доступ к члену полученного объекта. Например, если есть вектор строк, то может понадобиться узнать, не пуст ли некий элемент. С учетом, что it — это итератор данного вектора, можно следующим образом проверить, не пуста ли строка, на которую он указывает:
(*it).empty()
По причинам, рассматриваемым в разделе 4.1.2, круглые скобки в части (*it).empty() необходимы. Круглые скобки требуют применить оператор обращения к значению к итератору it, а к результату применить точечный оператор (см. раздел 1.5.2). Без круглых скобок точечный оператор относился бы к итератору it, а не к полученному объекту.
(*it).empty() // обращение к значению it и вызов функции-члена empty()
// полученного объекта
*it.empty() // ошибка: попытка вызова функции-члена empty()
// итератора it,
// но итератор it не имеет функции-члена empty()
Второе выражение интерпретируется как запрос на выполнение функции-члена empty() объекта it. Но it — это итератор, и он не имеет такой функции. Следовательно, второе выражение ошибочно.
Чтобы упростить такие выражения, язык предоставляет оператор стрелки (arrow operator) (оператор ->). Оператор стрелки объединяет обращение к значению и доступ к члену. Таким образом, выражение it->mem является синоним выражения (*it).mem.
Предположим, например, что имеется вектор vector
// отобразить каждую строку вектора text до первой пустой строки
for (auto it = text.cbegin();
it != text.cend() && !it->empty(); ++it)
cout << *it << endl;
Код начинается с инициализации итератора it указанием на первый элемент вектора text. Цикл продолжается до тех пор, пока не будут обработаны все элементы вектора text или пока не встретится пустой элемент. Пока есть элементы и текущий элемент не пуст, он отображается. Следует заметить, что, поскольку цикл только читает элементы, но не записывает их, здесь для управления итерацией используются функции cbegin() и cend().
Некоторые операции с векторами делают итераторы недопустимыми
В разделе 3.3.2 упоминался тот факт, что векторы способны расти динамически. Обращалось также внимание на то, что нельзя добавлять элементы в вектор в цикле серийного оператора for. Еще одно замечание: любая операция, такая как вызов функции push_back(), изменяет размер вектора и способна сделать недопустимыми все итераторы данного вектора. Более подробная информация по этой теме приведена в разделе 9.3.6.
На настоящий момент достаточно знать, что использующие итераторы цикла не должны добавлять элементы в контейнер, с которым связаны итераторы.
3.4.2. Арифметические действия с итераторами
Инкремент итератора перемещает его на один элемент. Инкремент поддерживают итераторы всех библиотечных контейнеров. Аналогично операторы == и != можно использовать для сравнения двух допустимых итераторов (см. раздел 3.4) любых библиотечных контейнеров.
Итераторы строк и векторов поддерживают дополнительные операции, позволяющие перемещать итераторы на несколько позиций за раз. Они также поддерживают все операторы сравнения. Эти операторы зачастую называют арифметическими действиями с итераторами (iterator arithmetic). Они приведены в табл. 3.7.
Таблица 3.7. Операции с итераторами векторов и строк
iter + n iter - n | Добавление (вычитание) целочисленного значения n к (из) итератору возвращает итератор, указывающий на элемент n позиций вперед (назад) в пределах контейнера. Полученный итератор должен указывать на элемент или на следующую позицию за концом того же контейнера |
iter1 += n iter1 -= n | Составные операторы присвоения со сложением и вычитанием итератора. Присваивает итератору iter1 значение на n позиций больше или меньше предыдущего |
iter1 - iter2 | Вычитание двух итераторов возвращает значение, которое, будучи добавлено к правому итератору, вернет левый. Итераторы должны указывать на элементы или на следующую позицию за концом того же контейнера |
> , >= , < , <= | Операторы сравнения итераторов. Один итератор меньше другого, если он указывает на элемент, расположенный в контейнере ближе к началу. Итераторы должны указывать на элементы или на следующую позицию за концом того же контейнера |
Арифметические операции с итераторами
К итератору можно добавить (или вычесть из него) целочисленное значение. Это вернет итератор, перемещенный на соответствующее количество позиций вперед (или назад). При добавлении или вычитании целочисленного значения из итератора результат должен указывать на элемент в том же векторе (или строке) или на следующую позицию за концом того же вектора (или строки). В качестве примера вычислим итератор на элемент, ближайший к середине вектора:
// вычислить итератор на элемент, ближайший к середине вектора vi
auto mid = vi.begin() + vi.size() / 2;
Если у вектора vi 20 элементов, то результатом vi.size()/2 будет 10. В данном случае переменной mid будет присвоено значение, равное vi.begin() + 10. С учетом, что нумерация индексов начинаются с 0, это тот же элемент, что и vi[10], т.е. элемент на десять позиций от начала.
Кроме сравнения двух итераторов на равенство, итераторы векторов и строк можно сравнить при помощи операторов сравнения (<, <=, >, >=). Итераторы должны быть допустимы, т.е. должны обозначать элементы (или следующую позицию за концом) того же вектора или строки. Предположим, например, что it является итератором в том же векторе, что и mid. Следующим образом можно проверить, указывает ли итератор it на элемент до или после итератора mid:
if (it < mid)
// обработать элементы в первой половине вектора vi
Можно также вычесть два итератора, если они указывают на элементы (или следующую позицию за концом) того же вектора или строки. Результат — дистанция между итераторами. Под дистанцией подразумевается значение, на которое следует изменить один итератор, чтобы получить другой. Результат имеет целочисленный знаковый тип difference_type. Тип difference_type определен и для вектора, и для строки. Этот тип знаковый, поскольку результатом вычитания может оказаться отрицательное значение.
Использование арифметических действий с итераторами
Классическим алгоритмом, использующим арифметические действия с итераторами, является двоичный поиск (binary search). Двоичный (бинарный) поиск ищет специфическое значение в отсортированной последовательности. Алгоритм работает так: сначала исследуется элемент, ближайший к середине последовательности. Если это искомый элемент, работа закончена. В противном случае, если этот элемент меньше искомого, поиск продолжается только среди элементов после исследованного. Если средний элемент больше искомого, поиск продолжается только в первой половине. Вычисляется новый средний элемент оставшегося диапазона, и действия продолжаются, пока искомый элемент не будет найден или пока не исчерпаются элементы.
Используя итераторы, двоичный поиск можно реализовать следующим образом:
// текст должен быть отсортирован
// beg и end ограничивают диапазон, в котором осуществляется поиск
auto beg = text.begin(), end = text.end();
auto mid = text.begin() + (end - beg)/2; // исходная середина
// пока еще есть элементы и искомый не найден
while (mid != end && *mid != sought) {
if (sought < *mid) // находится ли искомый элемент в первой половине?
end = mid; // если да, то изменить диапазон, игнорируя вторую
// половину
else // искомый элемент во второй половине
beg = mid + 1; // начать поиск с элемента сразу после середины
mid = beg + (end - beg)/2; // новая середина
}
Код начинается с определения трех итераторов: beg будет первым элементом в диапазоне, end — элементом после последнего, a mid — ближайшим к середине. Инициализируем эти итераторы значениями, охватывающими весь диапазон вектора vector
Сначала цикл проверяет, не пуст ли диапазон. Если значение итератора mid равно текущему значению итератора end, то элементы для поиска исчерпаны. В таком случае условие ложно и цикл while завершается. В противном случае итератор mid указывает на элемент, который проверяется на соответствие искомому. Если это так, то цикл завершается.
Если элементы все еще есть, код в цикле while корректирует диапазон, перемещая итератор end или beg. Если обозначенный итератором mid элемент больше, чем sought, то если искомый элемент и есть в векторе, он находится перед элементом, обозначенным итератором mid. Поэтому можно игнорировать элементы после середины, что мы и делаем, присваивая значение итератора mid итератору end. Если значение *mid меньше, чем sought, элемент должен быть в диапазоне элементов после обозначенного итератором mid. В данном случае диапазон корректируется присвоением итератору beg позиции сразу после той, на которую указывает итератор mid. Уже известно, что mid не указывает на искомый элемент, поэтому его можно исключить из диапазона.
В конце цикла while итератор mid будет равен итератору end либо будет указывать на искомый элемент. Если итератор mid равен end, то искомого элемента нет в векторе text.
Упражнения раздела 3.4.2
Упражнение 3.24. Переделайте последнее упражнение раздела 3.3.3 с использованием итераторов.
Упражнение 3.25. Перепишите программу кластеризации оценок из раздела 3.3.3 с использованием итераторов вместо индексации.
Упражнение 3.26. Почему в программе двоичного поиска использован код mid = beg + (end - beg) / 2;, а не mid = (beg + end) / 2;?
3.5. Массивы
Массив (array) — это структура данных, подобная библиотечному типу vector (см. раздел 3.3), но с другим соотношением между производительностью и гибкостью. Как и вектор, массив является контейнером безымянных объектов одинакового типа, к которым обращаются по позиции. В отличие от вектора, массивы имеют фиксированный размер; добавлять элементы к массиву нельзя. Поскольку размеры массивов постоянны, они иногда обеспечивают лучшую производительность во время выполнения приложений. Но это преимущество приобретается за счет потери гибкости.
Если вы не знаете точно, сколько элементов необходимо, используйте вектор.
3.5.1. Определение и инициализация встроенных массивов
Массив является составным типом (см. раздел 2.3). Оператор объявления массива имеет форму a[d], где а — имя; d — размерность определяемого массива. Размерность задает количество элементов массива, она должна быть больше нуля. Количество элементов — это часть типа массива, поэтому она должна быть известна на момент компиляции. Следовательно, размерность должна быть константным выражением (см. раздел 2.4.4).
unsigned cnt = 42; // неконстантное выражение
constexpr unsigned sz = 42; // константное выражение
// constexpr см. p. 2.4.4
int arr[10]; // массив десяти целых чисел
int *parr[sz]; // массив 42 указателей на int
string bad[cnt]; // ошибка: cnt неконстантное выражение
string strs[get_size()]; // ok, если get_size - constexpr,
// в противном случае - ошибка
По умолчанию элементы массива инициализируются значением по умолчанию (раздел 2.2.1).
Подобно переменным встроенного типа, инициализированный по умолчанию массив встроенного типа, определенный в функции, будет содержать неопределенные значения.
При определении массива необходимо указать тип его элементов. Нельзя использовать спецификатор auto для вывода типа из списка инициализаторов. Подобно вектору, массив содержит объекты. Таким образом, невозможен массив ссылок.
Явная инициализация элементов массива
Массив допускает списочную инициализацию (см. раздел 3.3.1) элементов. В этом случае размерность можно опустить. Если размерность отсутствует, компилятор выводит ее из количества инициализаторов. Если размерность определена, количество инициализаторов не должно превышать ее.
Если размерность больше количества инициализаторов, то инициализаторы используются для первых элементов, а остальные инициализируются по умолчанию (см. раздел 3.3.1):
const unsigned sz = 3;
int ia1[sz] = {0, 1, 2}; // массив из трех целых чисел со
// значениями 0, 1, 2
int a2[] = {0, 1, 2}; // массив размером 3 элемента
int a3[5] = {0, 1, 2}; // эквивалент a3[] = {0, 1, 2, 0, 0}
string a4[3]Согласно другой трактовке, исключение — это объект системного или пользовательского класса, создаваемого операционной системой или кодом программы в ответ на обстоятельства, либо не допускающие дальнейшего нормального выполнения программы, либо определенные пользователем. Обработка исключений в приложении позволяет корректно выйти из затруднительной ситуации. — Примеч. ред .
= {"hi", "bye"}; // эквивалент a4[] = {"hi", "bye", ""}
int a5[2]А также для временного отключения больших фрагментов кода при отладке. — Примеч. ред .
= {0, 1, 2}; // ошибка: слишком много инициализаторов
Особенности символьных массивов
У символьных массивов есть дополнительная форма инициализации: строковым литералом (см. раздел 2.1.3). Используя эту форму инициализации, следует помнить, что строковые литералы заканчиваются нулевым символом. Этот нулевой символ копируется в массив наряду с символами литерала.
char a1[] = {'C', '+', '+'}; // списочная инициализация без
// нулевого символа
char а2[] = {'C', '+', '+', '\0'}; // списочная инициализация с явным
// нулевым символом
char a3[] = "С++"; // нулевой символ добавляется
// автоматически
const char a4[6] = "Daniel"; // ошибка: нет места для нулевого
// символа!
Массив a1 имеет размерность 3; массивы а2 и a3 — размерности 4. Определение массива a4 ошибочно. Хотя литерал содержит только шесть явных символов, массив a4 должен иметь по крайней мере семь элементов, т.е. шесть для самого литерала и один для нулевого символа.
Не допускается ни копирование, ни присвоение
Нельзя инициализировать массив как копию другого массива, не допустимо также присвоение одного массива другому.
int a[] = {0, 1, 2}; // массив из трех целых чисел
int a2[] = a; // ошибка: нельзя инициализировать один массив
// другим
а2 = a; // ошибка: нельзя присваивать один массив другому
Некоторые компиляторы допускают присвоение массивов при применении расширения компилятора (compiler extension). Как правило, использования нестандартных средств следует избегать, поскольку они не будут работать на других компиляторах.
Понятие сложных объявлений массива
Как и векторы, массивы способны содержать объекты большинства типов. Например, может быть массив указателей. Поскольку массив — это объект, можно определять и указатели, и ссылки на массивы. Определение массива, содержащего указатели, довольно просто, определение указателя или ссылки на массив немного сложней.
int *ptrs[10]; // ptrs массив десяти указателей на int
int &refs[10] = /* ? */; // ошибка: массив ссылок невозможен
int (*Parray)[10] = &arr; // Parray указывает на массив из десяти int
int (&arrRef)[10] = arr; // arrRef ссылается на массив из десяти ints
Обычно модификаторы типа читают справа налево. Читаем определение ptrs справа налево (см. раздел 2.3.3): определить массив размером 10 по имени ptrs для хранения указателей на тип int.
Определение Parray также стоит читать справа налево. Поскольку размерность массива следует за объявляемым именем, объявление массива может быть легче читать изнутри наружу, а не справа налево. Так намного проще понять тип Parray. Объявление начинается с круглых скобок вокруг части *Parray, означающей, что Parray — указатель. Глядя направо, можно заметить, что указатель Parray указывает на массив размером 10. Глядя влево, можно заметить, что элементами этого массива являются целые числа. Таким образом, Parray — это указатель на массив из десяти целых чисел. Точно так же часть (&arrRef) означает, что arrRef — это ссылка, а типом, на который она ссылается, является массив размером 10, хранящий элементы типа int.
Конечно, нет никаких ограничений на количество применяемых модификаторов типа.
int *(&arry)[10]=ptrs; // arry - ссылка на массив из десяти указателей
Читая это объявление изнутри наружу, можно заметить, что arry — это ссылка. Глядя направо, можно заметить, что объект, на который ссылается arry, является массивом размером 10. Глядя влево, можно заметить, что типом элемента является указатель на тип int. Таким образом, arry — это ссылка на массив десяти указателей.
Зачастую объявление массива может быть проще понять, начав его чтение с имени массива и продолжив его изнутри наружу.
Упражнения раздела 3.5.1
Упражнение 3.27. Предположим, что функция txt_size() на получает никаких аргументов и возвращают значение типа int. Объясните, какие из следующих определений недопустимы и почему?
unsigned buf_size = 1024;
(a) int ia[buf_size]; (b) int ia[4 * 7 - 14];
(c) int ia[txt_size()]; (d) char st[11] = "fundamental";
Упражнение 3.28. Какие значения содержатся в следующих массивах?
string sa[10];
int ia[10];
int main() {
string sa2[10];
int ia2[10];
}
Упражнение 3.29. Перечислите некоторые из недостатков использования массива вместо вектора.
3.5.2. Доступ к элементам массива
Подобно библиотечным типам vector и string, для доступа к элементам массива можно использовать серийный оператор for или оператор индексирования ([]) (subscript). Как обычно, индексы начинаются с 0. Для массива из десяти элементов используются индексы от 0 до 9, а не от 1 до 10.
При использовании переменной для индексирования массива ее обычно определяют как имеющую тип size_t. Тип size_t — это машинозависимый беззнаковый тип, гарантированно достаточно большой для содержания размера любого объекта в памяти. Тип size_t определен в заголовке cstddef, который является версией С++ заголовка stddef.h библиотеки С.
За исключением фиксированного размера, массивы используются подобно векторам. Например, можно повторно реализовать программу оценок из раздела 3.3.3, используя для хранения счетчиков кластеров массив.
// подсчет количества оценок в кластере по десять: 0--9,
// 10--19, ... 90--99, 100
unsigned scores[11] = {}; // 11 ячеек, все со значением 0
unsigned grade;
while (cin >> grade) {
if (grade <= 100)
++scores[grade/10]; // приращение счетчика текущего кластера
}
Единственное очевидное различие между этой программой и приведенной в разделе 3.3.3 в объявлении массива scores. В данной программе это массив из 11 элементов типа unsigned. Не столь очевидно то различие, что оператор индексирования в данной программе тот, который определен как часть языка. Этот оператор применяется с операндами типа массива. Оператор индексирования, используемый в программе в разделе 3.3.3, был определен библиотечным шаблоном vector и применялся к операндам типа vector.
Как и в случае строк или векторов, для перебора всего массива лучше использовать серийный оператор for. Например, все содержимое массива scores можно отобразить следующим образом:
for (auto i : scores) // для каждого счетчика в scores
cout << i << " "; // отобразить его значение
cout << endl;
Поскольку размерность является частью типа каждого массива, системе известно количество элементов в массиве scores. Используя средства серийного оператора for, перебором можно управлять и не самостоятельно.
Проверка значений индекса
Как и в случае со строкой и вектором, ответственность за невыход индекса за пределы массива лежит на самом программисте. Он сам должен гарантировать, что значение индекса будет больше или равно нулю, но не больше размера массива. Ничто не мешает программе перешагнуть границу массива, кроме осторожности и внимания разработчика, а также полной проверки кода. В противном случае программа будет компилироваться и выполняться правильно, но все же содержать скрытую ошибку, способную проявиться в наименее подходящий момент.
Наиболее распространенным источником проблем защиты приложений является ошибка переполнения буфера. Причиной такой ошибки является отсутствие в программе проверки индекса, в результате чего программа ошибочно использует память вне диапазона массива или подобной структуры данных.
Упражнения раздела 3.5.2
Упражнение 3.30. Выявите ошибки индексации в следующем коде
constexpr size_t array size = 10;
int ia[array_size];
for (size_t ix = 1; ix <= array size; ++ix)
ia[ix] = ix;
Упражнение 3.31. Напишите программу, где определен массив из десяти целых чисел, каждому элементу которого присвоено значение, соответствующее его позиции в массиве.
Упражнение 3.32. Скопируйте массив, определенный в предыдущем упражнении, в другой массив. Перезапишите эту программу так, чтобы использовались векторы.
Упражнение 3.33. Что будет, если не инициализировать массив scores в программе оценок из данного раздела?
3.5.3. Указатели и массивы
Указатели и массивы в языке С++ тесно связаны. В частности, как будет продемонстрировано вскоре, при использовании массивов компилятор обычно преобразует их в указатель.
Обычно указатель на объект получают при помощи оператора обращения к адресу (см. раздел 2.3.2). По правде говоря, оператор обращения к адресу может быть применен к любому объекту, а элементы в массиве — объекты. При индексировании массива результатом является объект в этой области массива. Подобно любым другим объектам, указатель на элемент массива можно получить из адреса этого элемента:
string nums[] = {"one", "two", "three"}; // массив строк
string *p = &nums[0]; // p указывает на первый элемент массива nums
Однако у массивов есть одна особенность — места их использования компилятор автоматически заменяет указателем на первый элемент.
string *p2 = nums; // эквивалент p2 = &nums[0]
В большинстве выражений, где используется объект типа массива, в действительности используется указатель на первый элемент в этом массиве.
Существует множество свидетельств того факта, что операции с массивами зачастую являются операциями с указателями. Одно из них — при использовании массива как инициализатора переменной, определенной с использованием спецификатора auto (см. раздел 2.5.2), выводится тип указателя, а не массива.
int ia[] = {0,1,2,3,4,5,6,7,8,9}; // ia - массив из десяти целых чисел
auto ia2(ia); // ia2 - это int*, указывающий на первый элемент в ia
ia2 = 42; // ошибка: ia2 - указатель, нельзя присвоить указателю
// значение типа int
Хотя ia является массивом из десяти целых чисел, при его использовании в качестве инициализатора компилятор рассматривает это как следующий код:
auto ia2(&ia[0]); // теперь ясно, что ia2 имеет тип int*
Следует заметить, что это преобразование не происходит, если используется спецификатор decltype (см. раздел 2.5.3). Выражение decltype(ia) возвращает массив из десяти целых чисел:
// ia3 - массив из десяти целых чисел
decltype(ia) ia3 = {0,1,2,3,4,5,6,7,8,9};
ia3 = p; // ошибка: невозможно присвоить int* массиву
ia3[4]Здесь и везде в оригинале именно adapt o r, а не adapt e r. — Примеч. ред .
= i; // ok: присвоить значение i элементу в массиве ia3
Указатели — это итераторы
Указатели, содержащие адреса элементов в массиве, обладают дополнительными возможностями, кроме описанных в разделе 2.3.2. В частности, указатели на элементы массивов поддерживают те же операции, что и итераторы векторов или строк (см. раздел 3.4). Например, можно использовать оператор инкремента для перемещения с одного элемента массива на следующий:
int arr[] = {0,1,2,3,4,5,6,7,8,9};
int *p = arr; // p указывает на первый элемент в arr
++p; // p указывает на arr[1]На самом деле препроцессору. Компилятор получит готовый промежуточный файл, в состав которого войдет содержимое подключенной библиотеки. — Примеч. ред .
Подобно тому, как итераторы можно использовать для перебора элементов вектора, указатели можно использовать для перебора элементов массива. Конечно, для этого нужно получить указатели на первый элемент и элемент, следующий после последнего. Как упоминалось только что, указатель на первый элемент можно получить при помощи самого массива или при обращении к адресу первого элемента. Получить указатель на следующий элемент после последнего можно при помощи другого специального свойства массива. Последний элемент массива arr находится в позиции 9, а адрес несуществующего элемента массива, следующего после него, можно получить так:
int *е = &arr[10]; // указатель на элемент после
// последнего в массиве arr
Единственное, что можно сделать с этим элементом, так это получить его адрес, чтобы инициализировать указатель е. Как и итератор на элемент после конца (см. раздел 3.4.1), указатель на элемент после конца не указывает ни на какой элемент. Поэтому нельзя ни обратиться к его значению, ни прирастить.
Используя эти указатели, можно написать цикл, выводящий элементы массива arr.
for (int *b = arr; b != e; ++b)
cout << *b << endl; // вывод элементов arr
Библиотечные функции begin() и end()
Указатель на элемент после конца можно вычислить, но этот подход подвержен ошибкам. Чтобы облегчить и обезопасить использование указателей, новая библиотека предоставляет две функции: begin() и end(). Эти функции действуют подобно одноименным функциям-членам контейнеров (см. раздел 3.4.1). Однако массивы — не классы, и данные функции не могут быть функциями-членами. Поэтому для работы они получают массив в качестве аргумента.
int ia[] = {0,1,2,3,4,5,6,7,8,9}; // ia - массив из десяти целых чисел
int *beg = begin(ia); // указатель на первый элемент массива ia
int *last = end(ia); // указатель на следующий элемент ia за последним
Функция begin() возвращает указатель на первый, а функция end() на следующий после последнего элемент данного массива. Эти функции определены в заголовке iterator.
Используя функции begin() и end(), довольно просто написать цикл обработки элементов массива. Предположим, например, что массив arr содержит значения типа int. Первое отрицательное значение в массиве arr можно найти следующим образом:
// pbeg указывает на первый, a pend на следующий после последнего
// элемент массива arr
int *pbeg = begin(arr), *pend = end(arr);
// найти первый отрицательный элемент, остановиться, если просмотрены
// все элементы
while (pbeg != pend && *pbeg >= 0)
++pbeg;
Код начинается с определения двух указателей типа int по имени pbeg и pend. Указатель pbeg устанавливается на первый элемент массива arr, a pend — на следующий элемент после последнего. Условие цикла while использует указатель pend, чтобы узнать, безопасно ли обращаться к значению указателя pbeg. Если указатель pbeg действительно указывает на элемент, выполняется проверка результата обращения к его значению на наличие отрицательного значения. Если это так, то условие ложно и цикл завершается. В противном случае указатель переводится на следующий элемент.
Указатель на элемент "после последнего" у встроенного массива ведет себя так же, как итератор, возвращенный функцией end() вектора. В частности, нельзя ни обратиться к значению такого указателя, ни осуществить его приращение.
Арифметические действия с указателями
Указатели на элементы массива позволяют использовать все операции с итераторами, перечисленные в табл. 3.6 и 3.7. Эти операции, обращения к значению, инкремента, сравнения, добавления целочисленного значения, вычитания двух указателей, имеют для указателей на элементы встроенного массива то же значение, что и для итераторов.
Результатом добавления (или вычитания) целочисленного значения к указателю (или из него) является новый указатель, указывающий на элемент, расположенный на заданное количество позиций вперед (или назад) от исходного указателя.
constexpr size_t sz = 5;
int arr[sz] = {1,2,3,4,5};
int *ip = arr; // эквивалент int *ip = &arr[0]
int *ip2 = ip + 4; // ip2 указывает на arr[4]Здесь и везде в оригинале именно adapt o r, а не adapt e r. — Примеч. ред .
, последний элемент в arr
Результатом добавления 4 к указателю ip будет указатель на элемент, расположенный в массиве на четыре позиции далее от того, на который в настоящее время указывает ip.
Результатом добавления целочисленного значения к указателю должен быть указатель на элемент (или следующую позицию после конца) в том же массиве:
// ok: arr преобразуется в указатель на его первый элемент;
// p указывает на позицию после конца arr
int *p = arr + sz; // использовать осмотрительно - не обращаться
// к значению!
int *p2 = arr + 10; // ошибка: arr имеет только 5 элементов;
// значение p2 неопределенно
При сложении arr и sz компилятор преобразует arr в указатель на первый элемент массива arr. При добавлении sz к этому указателю получается указатель на позицию sz (т.е. на позицию 5) этого массива. Таким образом, он указывает на следующую позицию после конца массива arr. Вычисление указателя на более чем одну позицию после последнего элемента является ошибкой, хотя компилятор таких ошибок не обнаруживает.
Подобно итераторам, вычитание двух указателей дает дистанцию между ними. Указатели должны указывать на элементы в том же массиве:
auto n = end(arr) - begin(arr); // n - 5, количество элементов
// массива arr
Результат вычитания двух указателей имеет библиотечный тип ptrdiff_t. Как и тип size_t, тип ptrdiff_t является машинозависимым типом, определенным в заголовке cstddef. Поскольку вычитание способно возвратить отрицательное значение, тип ptrdiff_t — знаковый целочисленный.
Для сравнения указателей на элементы (или позицию за концом) массива можно использовать операторы сравнения. Например, элементы массива arr можно перебрать следующим образом:
int *b = arr, *е = arr + sz;
while (b < e) {
// используется *b
++b;
}
Нельзя использовать операторы сравнения для указателей на два несвязанных объекта.
int i = 0, sz = 42;
int *p = &i, *е = &sz;
// неопределенно: p и е не связаны; сравнение бессмысленно!
while (p < е)
Хотя на настоящий момент смысл может быть и неясен, но следует заметить, что арифметические действия с указателями допустимы также для нулевых указателей (см. раздел 2.3.2) и для указателей на объекты, не являющиеся массивом. В последнем случае указатели должны указывать на тот же объект или следующий после него. Если p — нулевой указатель, то к нему можно добавить (или вычесть) целочисленное константное выражение (см. раздел 2.4.4) со значением 0. Можно также вычесть два нулевых указателя из друг друга, и результатом будет 0.
Взаимодействие обращения к значению с арифметическими действиями с указателями
Результатом добавления целочисленного значения к указателю является указатель. Если полученный указатель указывает на элемент, то к его значению можно обратиться:
int ia[] = {0,2,4,6,8}; // массив из 5 элементов типа int
int last = *(ia + 4); // ok: инициализирует last значением
// ia[4]Здесь и везде в оригинале именно adapt o r, а не adapt e r. — Примеч. ред .
, т.е. 8
Выражение *(ia + 4) вычисляет адрес четвертого элемента после ia и обращается к значению полученного указателя. Это выражение эквивалентно выражению ia[4]Здесь и везде в оригинале именно adapt o r, а не adapt e r. — Примеч. ред .
.
Помните, в разделе 3.4.1 обращалось внимание на необходимость круглых скобок в выражениях, содержащих оператор обращения к значению и точечный оператор. Аналогично необходимы круглые скобки вокруг части сложения указателей:
last = *ia + 4; // ok: last = 4, эквивалент ia[0] + 4
Этот код обращается к значению ia и добавляет 4 к полученному значению. Причины подобного поведения рассматриваются в разделе 4.1.2.
#magnify.png Индексирование и указатели
Как уже упоминалось, в большинстве мест, где используется имя массива, в действительности используется указатель на первый элемент этого массива. Одним из мест, где компилятор осуществляет это преобразование, является индексирование массива.
int ia[] = {0,2,4,6,8}; // массив из 5 элементов типа int
Рассмотрим выражение ia[0], использующее имя массива. При индексировании массива в действительности индексируется указатель на элемент в этом массиве.
int i = ia[2]А также для временного отключения больших фрагментов кода при отладке. — Примеч. ред .
; // ia преобразуется в указатель на первый элемент ia
// ia[2]А также для временного отключения больших фрагментов кода при отладке. — Примеч. ред .
выбирает элемент, на который указывает (ia + 2)
int *p = ia; // p указывает на первый элемент в массиве ia
i = *(p + 2); // эквивалент i = ia[2]А также для временного отключения больших фрагментов кода при отладке. — Примеч. ред .
Оператор индексирования можно использовать для любого указателя, пока он указывает на элемент (или позицию после конца) в массиве.
int *p = &ia[2]А также для временного отключения больших фрагментов кода при отладке. — Примеч. ред .
; // p указывает на элемент с индексом 2
int j = p[1]На самом деле препроцессору. Компилятор получит готовый промежуточный файл, в состав которого войдет содержимое подключенной библиотеки. — Примеч. ред .
На самом деле препроцессору. Компилятор получит готовый промежуточный файл, в состав которого войдет содержимое подключенной библиотеки. — Примеч. ред .
; // p[1]На самом деле препроцессору. Компилятор получит готовый промежуточный файл, в состав которого войдет содержимое подключенной библиотеки. — Примеч. ред .
На самом деле препроцессору. Компилятор получит готовый промежуточный файл, в состав которого войдет содержимое подключенной библиотеки. — Примеч. ред .
- эквивалент *(p + 1),
// p[1]На самом деле препроцессору. Компилятор получит готовый промежуточный файл, в состав которого войдет содержимое подключенной библиотеки. — Примеч. ред .
тот же элемент, что и ia[3]Согласно другой трактовке, исключение — это объект системного или пользовательского класса, создаваемого операционной системой или кодом программы в ответ на обстоятельства, либо не допускающие дальнейшего нормального выполнения программы, либо определенные пользователем. Обработка исключений в приложении позволяет корректно выйти из затруднительной ситуации. — Примеч. ред .
int k = p[-2]; // p[-2] тот же элемент, что и ia[0]
Последний пример указывает на важное отличие между массивами и такими библиотечными типами, как vector и string, у которых есть операторы индексирования. Библиотечные типы требуют, чтобы используемый индекс был беззнаковым значением. Встроенный оператор индексирования этого не требует. Индекс, используемый со встроенным оператором индексирования, может быть отрицательным значением. Конечно, полученный адрес должен указывать на элемент (или позицию после конца) массива, на который указывает первоначальный указатель.
В отличие от индексов для векторов и строк, индекс встроенного массива не является беззнаковым.
Упражнения раздела 3.5.3
Упражнение 3.34. С учетом, что указатели p1 и p2 указывают на элементы в том же массиве, что делает следующий код? Какие значения p1 или p2 делают этот код недопустимым?
p1 += p2 - p1;
Упражнение 3.35. Напишите программу, которая использует указатели для обнуления элементов массива.
Упражнение 3.36. Напишите программу, сравнивающую два массива на равенство. Напишите подобную программу для сравнения двух векторов.
3.5.4. Символьные строки в стиле С
Хотя язык С++ поддерживает строки в стиле С, использовать их в программах С++ не следует. Строки в стиле С — на удивление богатый источник разнообразных ошибок и наиболее распространенная причина проблем защиты.
Символьный строковый литерал — это экземпляр более общей конструкции, которую язык С++ унаследовал от языка С: символьной строки в стиле С (C-style character string). Строка в стиле С не является типом данных, скорее это соглашение о представлении и использовании символьных строк. Следующие этому соглашению строки хранятся в символьных массивах и являются строкой с завершающим нулевым символом (null-terminated string). Под завершающим нулевым символом подразумевается, что последний видимый символ в строке сопровождается нулевым символом ('\0'). Для манипулирования этими строками обычно используются указатели.
Строковые функции библиотеки С
Стандартная библиотека языка С предоставляет набор функций, перечисленных в табл. 3.8, для работы со строками в стиле С. Эти функции определены в заголовке cstring, являющемся версией С++ заголовка языка С string.h.
Функции из табл. 3.8 не проверяют свои строковые параметры
Указатель (указатели), передаваемый этим функциям, должен указывать на массив (массивы) с нулевым символом в конце.
char ca[] = {'C', '+', '+'}; // без нулевого символа в конце
cout << strlen(ca) << endl; // катастрофа: ca не завершается нулевым
// символом
В данном случае ca — это массив элементов типа char, но он не завершается нулевым символом. Результат непредсказуем. Вероятней всего, функция strlen() продолжит просматривать память уже за пределами массива ca, пока не встретит нулевой символ.
Таблица 3.8. Функции для символьных строк в стиле С
strlen(p) | Возвращает длину строки p без учета нулевого символа |
strcmp(p1, p2) | Проверяет равенство строк p1 и p2 . Возвращает 0 , если p1 == p2 , положительное значение, если p1 > p2 , и отрицательное значение, если p1 < p2 |
strcat(p1, p2) | Добавляет строку p2 к p1 . Результат возвращает в строку p1 |
strcpy(p1, p2) | Копирует строку p2 в строку p1 . Результат возвращает в строку p1 |
Сравнение строк
Сравнение двух строк в стиле С осуществляется совсем не так, как сравнение строк библиотечного типа string. При сравнении библиотечных строк используются обычные операторы равенства или сравнения:
string s1 = "A string example";
string s2 = "A different string";
if (s1 < s2) // ложно: s2 меньше s1
Использование этих же операторов для подобным образом определенных строк в стиле С приведет к сравнению значений указателей, а не самих строк.
const char ca1[] = "A string example";
const char ca2[] = "A different string";
if (ca1 < ca2) // непредсказуемо: сравниваются два адреса
Помните, что при использовании массива в действительности используются указатели на их первый элемент (см. раздел 3.5.3). Следовательно, это условие фактически сравнивает два значения const char*. Эти указатели содержат адреса разных объектов, поэтому результат такого сравнения непредсказуем.
Чтобы сравнить строки, а не значения указателей, можем использовать функцию strcmp(). Она возвращает значение 0, если строки равны, положительное или отрицательное значение, в зависимости от того, больше ли первая строка второй или меньше.
if (strcmp(ca1, ca2) < 0) // то же, что и сравнение строк s1 < s2
За размер строки отвечает вызывающая сторона
Конкатенация и копирование строк в стиле С также весьма отличается от таких же операций с библиотечным типом string. Например, если необходима конкатенация строк s1 и s2, определенных выше, то это можно сделать так:
// инициализировать largeStr результатом конкатенации строки s1,
// пробела и строки s2
string largeStr = s1 + " " + s2;
Подобное с двумя массивами, ca1 и ca2, было бы ошибкой. Выражение ca1 + ca2 попытается сложить два указателя, что некорректно и бессмысленно.
Вместо этого можно использовать функции strcat() и strcpy(). Но чтобы использовать эти функции, им необходимо передать массив для хранения результирующей строки. Передаваемый массив должен быть достаточно большим, чтобы содержать созданную строку, включая нулевой символ в конце. Хотя представленный здесь код следует традиционной схеме, потенциально он может стать причиной серьезной ошибки.
// катастрофа, если размер largeStr вычислен ошибочно
strcpy(largeStr, ca1); // копирует ca1 в largeStr
strcat(largeStr, " "); // добавляет пробел в конец largeStr
strcat(largeStr, ca2); // конкатенирует ca2 с largeStr
Проблема в том, что можно легко ошибиться в расчете необходимого размера largeStr. Кроме того, при каждом изменении значения, которые следует сохранить в largeStr, необходимо перепроверить правильность вычисления его размера. К сожалению, код, подобный этому, широко распространен в программах. Такие программы подвержены ошибкам и часто приводят к серьезным проблемам защиты.
Для большинства приложений не только безопасней, но и эффективней использовать библиотечный тип string, а не строки в стиле С.
Упражнения раздела 3.5.4
Упражнение 3.37. Что делает следующая программа?
const char ca[] = {'h', 'e', 'l', 'l', 'o'};
const char *cp = ca;
while (*cp) {
cout << *cp << endl;
++cp;
}
Упражнение 3.38. В этом разделе упоминалось, что не только некорректно, но и бессмысленно пытаться сложить два указателя. Почему сложение двух указателей бессмысленно?
Упражнение 3.39. Напишите программу, сравнивающую две строки. Затем напишите программу, сравнивающую значения двух символьных строк в стиле С.
Упражнение 3.40. Напишите программу, определяющую два символьных массива, инициализированных строковыми литералами. Теперь определите третий символьный массив для содержания результата конкатенации этих двух массивов. Используйте функции strcpy() и strcat() для копирования этих двух массивов в третий.
3.5.5. Взаимодействие с устаревшим кодом
Множество программ С++ было написано до появления стандартной библиотеки, поэтому они не используют библиотечные типы string и vector. Кроме того, многие программы С++ взаимодействуют с программами, написанными на языке С или других языках, которые не могут использовать библиотеку С++. Следовательно, программам, написанным на современном языке С++, вероятно, придется взаимодействовать с кодом, который использует символьные строки в стиле С и/или массивы. Библиотека С++ предоставляет средства, облегчающие такое взаимодействие.
#reader.png Совместное использование библиотечных строки строк в стиле С
В разделе 3.2.1 была продемонстрирована возможность инициализации строки класса string строковым литералом:
string s("Hello World"); // s содержит Hello World
В общем, символьный массив с нулевым символом в конце можно использовать везде, где используется строковый литерал.
• Символьный массив с нулевым символом в конце можно использовать для инициализации строки класса string или присвоения ей.
• Символьный массив с нулевым символом в конце можно использовать как один из операндов (но не оба) в операторе суммы класса string или как правый операнд в составном операторе присвоения (+=) класса string.
Однако нет никакого простого способа использовать библиотечную строку там, где требуется строка в стиле С. Например, невозможно инициализировать символьный указатель объектом класса string. Тем не менее класс string обладает функцией-членом c_str(), зачастую позволяющей выполнить желаемое.
char *str = s; // ошибка: нельзя инициализировать char* из string
const char *str = s.c_str(); // ok
Имя функции c_str() означает, что она возвращает символьную строку в стиле С. Таким образом, она возвращает указатель на начало символьного массива с нулевым символом в конце, содержащим те же символы, что и строка. Тип указателя const char* не позволяет изменять содержимое массива.
Допустимость массива, возвращенного функцией c_str(), не гарантируется. Любое последующее использование указателя s, способное изменить его значение, может сделать этот массив недопустимым.
Если программа нуждается в продолжительном доступе к содержимому массива, возвращенного функцией c_str(), то следует создать его копию.
Использование массива для инициализации вектора
В разделе 3.5.1 упоминалось о том, что нельзя инициализировать встроенный массив другим массивом. Инициализировать массив из вектора также нельзя. Однако можно использовать массив для инициализации вектора. Для этого необходимо определить адрес первого подлежащего копированию элемента и элемента, следующего за последним.
int int_arr[] = {0, 1, 2, 3, 4, 5};
// вектор ivec содержит 6 элементов, каждый из которых является
// копией соответствующего элемента массива int_arr
vector
Два указателя, используемые при создании вектора ivec, отмечают диапазон значений, используемых для инициализации его элементов. Второй указатель указывает на следующий элемент после последнего копируемого. В данном случае для передачи указателей на первый и следующий после последнего элементы массива int_arr использовались библиотечные функции begin() и end() (см. раздел 3.5.3). В результате вектор ivec содержит шесть элементов, значения которых совпадают со значениями соответствующих элементов массива int_arr.
Определяемый диапазон может быть также подмножеством массива:
// скопировать 3 элемента: int_arr[1]На самом деле препроцессору. Компилятор получит готовый промежуточный файл, в состав которого войдет содержимое подключенной библиотеки. — Примеч. ред .
, int_arr[2]А также для временного отключения больших фрагментов кода при отладке. — Примеч. ред .
, int_arr[3]Согласно другой трактовке, исключение — это объект системного или пользовательского класса, создаваемого операционной системой или кодом программы в ответ на обстоятельства, либо не допускающие дальнейшего нормального выполнения программы, либо определенные пользователем. Обработка исключений в приложении позволяет корректно выйти из затруднительной ситуации. — Примеч. ред .
vector
Этот код создает вектор subVec с тремя элементами, значения которых являются копиями значений элементов от intarr[1]На самом деле препроцессору. Компилятор получит готовый промежуточный файл, в состав которого войдет содержимое подключенной библиотеки. — Примеч. ред .
до intarr[3]Согласно другой трактовке, исключение — это объект системного или пользовательского класса, создаваемого операционной системой или кодом программы в ответ на обстоятельства, либо не допускающие дальнейшего нормального выполнения программы, либо определенные пользователем. Обработка исключений в приложении позволяет корректно выйти из затруднительной ситуации. — Примеч. ред .
.
Совет. Используйте вместо массивов библиотечные типы
Указатели и массивы на удивление сильно подвержены ошибкам. Частично проблема в концепции: указатели используются для низкоуровневых манипуляций, в них очень просто сделать тривиальные ошибки. Другие проблемы возникают из-за используемого синтаксиса, особенно синтаксиса объявлений.
Упражнения раздела 3.5.5
Упражнение 3.41. Напишите программу, инициализирующую вектор значениями из массива целых чисел.
Упражнение 3.42. Напишите программу, копирующую вектор целых чисел в массив целых чисел.
3.6. Многомерные массивы
Строго говоря, никаких многомерных массивов (multidimensioned array) в языке С++ нет. То, что обычно упоминают как многомерный массив, фактически является массивом массивов. Не забывайте об этом факте, когда будете использовать то, что называют многомерным массивом.
При определении массива, элементы которого являются массивами, указываются две размерности: размерность самого массива и размерность его элементов.
int ia[3]Согласно другой трактовке, исключение — это объект системного или пользовательского класса, создаваемого операционной системой или кодом программы в ответ на обстоятельства, либо не допускающие дальнейшего нормального выполнения программы, либо определенные пользователем. Обработка исключений в приложении позволяет корректно выйти из затруднительной ситуации. — Примеч. ред .
[4]Здесь и везде в оригинале именно adapt o r, а не adapt e r. — Примеч. ред .
; // массив из 3 элементов; каждый из которых является
// массивом из 4 целых чисел
// массив из 10 элементов, каждый из которых является массивом из 20
// элементов, каждый из которых является массивом из 30 целых чисел
int arr[10][20][30] = {0}; // инициализировать все элементы значением 0
Как уже упоминалось в разделе 3.5.1, может быть легче понять эти определения, читая их изнутри наружу. Сначала можно заметить определяемое имя, ia, далее видно, что это массив размером 3. Продолжая вправо, видим, что у элементов массива ia также есть размерность. Таким образом, элементы массива ia сами являются массивами размером 4. Глядя влево, видно, что типом этих элементов является int. Так, ia является массивом из трех элементов, каждый из которых является массивом из четырех целых чисел.
Прочитаем определение массива arr таким же образом. Сначала увидим, что arr — это массив размером 10 элементов. Элементы этого массива сами являются массивами размером 20 элементов. У каждого из этих массивов по 30 элементов типа int. Нет предела количеству используемых индексирований. Поэтому вполне может быть массив, элементы которого являются массивами массив, массив, массив и т.д.
В двумерном массиве первую размерность зачастую называют рядом (row), а вторую — столбцом (column).
Инициализация элементов многомерного массива
Подобно любым массивам, элементы многомерного массива можно инициализировать, предоставив в фигурных скобках список инициализаторов. Многомерные массивы могут быть инициализированы списками значений в фигурных скобках для каждого ряда.
int ia[3]Согласно другой трактовке, исключение — это объект системного или пользовательского класса, создаваемого операционной системой или кодом программы в ответ на обстоятельства, либо не допускающие дальнейшего нормального выполнения программы, либо определенные пользователем. Обработка исключений в приложении позволяет корректно выйти из затруднительной ситуации. — Примеч. ред .
[4]Здесь и везде в оригинале именно adapt o r, а не adapt e r. — Примеч. ред .
= { // три элемента; каждый - массив размером 4
{0, 1, 2, 3}, // инициализаторы ряда 0
{4, 5, 6, 7}, // инициализаторы ряда 1
{8, 9, 10, 11} // инициализаторы ряда 2
};
Вложенные фигурные скобки необязательны. Следующая инициализация эквивалентна, хотя и значительно менее очевидна:
// эквивалентная инициализация без необязательных вложенных фигурных
// скобок для каждого ряда
int ia[3]Согласно другой трактовке, исключение — это объект системного или пользовательского класса, создаваемого операционной системой или кодом программы в ответ на обстоятельства, либо не допускающие дальнейшего нормального выполнения программы, либо определенные пользователем. Обработка исключений в приложении позволяет корректно выйти из затруднительной ситуации. — Примеч. ред .
[4]Здесь и везде в оригинале именно adapt o r, а не adapt e r. — Примеч. ред .
= {0,1,2,3,4,5,6,7,8,9,10,11};
Как и в случае одномерных массивов, элементы списка инициализации могут быть пропущены. Следующим образом можно инициализировать только первый элемент каждого ряда:
// явная инициализация только нулевого элемента в каждом ряду
int ia[3]Согласно другой трактовке, исключение — это объект системного или пользовательского класса, создаваемого операционной системой или кодом программы в ответ на обстоятельства, либо не допускающие дальнейшего нормального выполнения программы, либо определенные пользователем. Обработка исключений в приложении позволяет корректно выйти из затруднительной ситуации. — Примеч. ред .
[4]Здесь и везде в оригинале именно adapt o r, а не adapt e r. — Примеч. ред .
= {{ 0 }, { 4 }, { 8 } };
Остальные элементы инициализируются значением по умолчанию, как и обычные одномерные массивы (см. раздел 3.5.1). Но если опустить вложенные фигурные скобки, то результаты были бы совсем иными:
// явная инициализация нулевого ряда;
// остальные элементы инициализируются
// по умолчанию
int ix[3]Согласно другой трактовке, исключение — это объект системного или пользовательского класса, создаваемого операционной системой или кодом программы в ответ на обстоятельства, либо не допускающие дальнейшего нормального выполнения программы, либо определенные пользователем. Обработка исключений в приложении позволяет корректно выйти из затруднительной ситуации. — Примеч. ред .
[4]Здесь и везде в оригинале именно adapt o r, а не adapt e r. — Примеч. ред .
= {0, 3, 6, 9};
Этот код инициализирует элементы первого ряда. Остальные элементы инициализируются значением 0.
Индексация многомерных массивов
Подобно любому другому массиву, для доступа к элементам многомерного массива можно использовать индексирование. При этом для каждой размерности используется отдельный индекс.
Если выражение предоставляет столько же индексов, сколько у массива размерностей, получается элемент с определенным типом. Если предоставить меньше индексов, чем есть размерностей, то результатом будет элемент внутреннего массива по указанному индексу:
// присваивает первый элемент массива arr последнему элементу
// в последнем ряду массива ia
ia[2]А также для временного отключения больших фрагментов кода при отладке. — Примеч. ред .
[3]Согласно другой трактовке, исключение — это объект системного или пользовательского класса, создаваемого операционной системой или кодом программы в ответ на обстоятельства, либо не допускающие дальнейшего нормального выполнения программы, либо определенные пользователем. Обработка исключений в приложении позволяет корректно выйти из затруднительной ситуации. — Примеч. ред .
= arr[0][0][0];
int (&row)[4]Здесь и везде в оригинале именно adapt o r, а не adapt e r. — Примеч. ред .
= ia[1]На самом деле препроцессору. Компилятор получит готовый промежуточный файл, в состав которого войдет содержимое подключенной библиотеки. — Примеч. ред .
; // связывает ряд второго массива с четырьмя
// элементами массива ia
В первом примере предоставляются индексы для всех размерностей обоих массивов. Левая часть, ia[2]А также для временного отключения больших фрагментов кода при отладке. — Примеч. ред .
, возвращает последний ряд массива ia. Она возвращает не отдельный элемент массива, а сам массив. Индексируем массив, выбирая элемент [3]Согласно другой трактовке, исключение — это объект системного или пользовательского класса, создаваемого операционной системой или кодом программы в ответ на обстоятельства, либо не допускающие дальнейшего нормального выполнения программы, либо определенные пользователем. Обработка исключений в приложении позволяет корректно выйти из затруднительной ситуации. — Примеч. ред .
, являющийся последним элементом данного массива.
Точно так же, правый операнд имеет три размерности. Сначала выбирается массив по индексу 0 из наиболее удаленного массива. Результат этой операции — массив (многомерный) размером 20. Используя массив размером 30, извлекаем из этого массива с 20 элементами первый элемент. Затем выбирается первый элемент из полученного массива.
Во втором примере row определяется как ссылка на массив из четырех целых чисел. Эта ссылка связывается со вторым рядом массива ia.
constexpr size_t rowCnt = 3, colCnt = 4;
int ia[rowCnt][colCnt]; // 12 неинициализированных элементов
// для каждого ряда
for (size_t i = 0; i != rowCnt; ++i) {
// для каждого столбца в ряду
for (size_t j = 0; j != colCnt; ++j) {
// присвоить элементу его индекс как значение
ia[i][j] = i * colCnt + j;
}
}
Внешний цикл for перебирает каждый элемент массива ia. Внутренний цикл for перебирает элементы внутренних массивов. В данном случае каждому элементу присваивается значение его индекса в общем массиве.
#magnify.png Использование серийного оператора for с многомерными массивами
По новому стандарту предыдущий цикл можно упростить с помощью серийного оператора for:
size_t cnt = 0;
for (auto &row : ia) // для каждого элемента во внешнем массиве
for (auto &col : row) { // для каждого элемента во внутреннем массиве
col = cnt; // присвоить значение текущему элементу
++cnt; // инкремент cnt
}
Этот цикл присваивает элементам массива ia те же значения, что и предыдущий цикл, но на сей раз управление индексами берет на себя система. Значения элементов необходимо изменить, поэтому объявляем управляющие переменные row и col как ссылки (см. раздел 3.2.3). Первый оператор for перебирает элементы массива ia, являющиеся массивами из 4 элементов. Таким образом, типом row будет ссылка на массив из четырех целых чисел. Второй цикл for перебирает каждый из этих массивов по 4 элемента. Следовательно, col имеет тип int&. На каждой итерации значение cnt присваивается следующему элементу массива ia, а затем осуществляется инкремент переменной cnt.
В предыдущем примере как управляющие переменные цикла использовались ссылки, поскольку элементы массива необходимо было изменять. Однако есть и более серьезная причина для использования ссылок. Рассмотрим в качестве примера следующий цикл:
for (const auto &row : ia) // для каждого элемента во внешнем массиве
for (auto col : row) // для каждого элемента во внутреннем массиве
cout << col << endl;
Этому циклу запись в элементы не нужна, но все же управляющая переменная внешнего цикла определена как ссылка. Это сделано для того, чтобы избежать преобразования обычного массива в указатель (см. раздел 3.5.3). Если пренебречь ссылкой и написать эти циклы так, то компиляция потерпит неудачу:
for (auto row : ia)
for (auto col : row)
Как и прежде, первый цикл for перебирает элементы массива ia, являющиеся массивами по 4 элемента. Поскольку row не ссылка, при его инициализации компилятор преобразует каждый элемент массива (как и любой другой объект типа массива) в указатель на первый элемент этого массива. В результате типом row в этом цикле будет int*. Внутренний цикл for некорректен. Несмотря на намерения разработчика, этот цикл пытается перебрать указатель типа int*.
Чтобы использовать многомерный массив в серийном операторе for, управляющие переменные всех циклов, кроме самого внутреннего, должны быть ссылками.
Указатели и многомерные массивы
Подобно любым другим массивам, имя многомерного массива автоматически преобразуется в указатель на первый его элемент.
Определяя указатель на многомерный массив, помните, что на самом деле он является массивом массивов.
Поскольку многомерный массив в действительности является массивом массивов, тип указателя, в который преобразуется массив, является типом первого внутреннего массива.
int ia[3]Согласно другой трактовке, исключение — это объект системного или пользовательского класса, создаваемого операционной системой или кодом программы в ответ на обстоятельства, либо не допускающие дальнейшего нормального выполнения программы, либо определенные пользователем. Обработка исключений в приложении позволяет корректно выйти из затруднительной ситуации. — Примеч. ред .
[4]Здесь и везде в оригинале именно adapt o r, а не adapt e r. — Примеч. ред .
; // массив размером 3 элемента; каждый элемент - массив
// из 4 целых чисел
int (*p)[4]Здесь и везде в оригинале именно adapt o r, а не adapt e r. — Примеч. ред .
= ia; // p указывает на массив из четырех целых чисел
p = &ia[2]А также для временного отключения больших фрагментов кода при отладке. — Примеч. ред .
; // теперь p указывает на последний элемент ia
Применяя стратегию из раздела 3.5.1, начнем рассмотрение с части (*p), гласящей, что p — указатель. Глядя вправо, замечаем, что объект, на который указывает указатель p, имеет размер 4 элемента, а глядя влево, видим, что типом элемента является int. Следовательно, p — это указатель на массив из четырех целых чисел.
Круглые скобки в этом объявлении необходимы.
int *ip[4]Здесь и везде в оригинале именно adapt o r, а не adapt e r. — Примеч. ред .
; // массив указателей на int
int (*ip)[4]Здесь и везде в оригинале именно adapt o r, а не adapt e r. — Примеч. ред .
; // указатель на массив из четырех целых чисел
Новый стандарт зачастую позволяет избежать необходимости указывать тип указателя на массив за счет использования спецификаторов auto и decltype (см. раздел 2.5.2).
// вывести значение каждого элемента ia; каждый внутренний массив
// отображается в отдельной строке
// p указывает на массив из четырех целых чисел
for (auto p = ia; p != ia + 3; ++p) {
// q указывает на первый элемент массива из четырех целых чисел;
// т.е. q указывает на int
for (auto q = *p; q != *p + 4; ++q)
cout << *q << ' '; cout << endl;
}
Внешний цикл for начинается с инициализации указателя p адресом первого массива в массиве ia. Этот цикл продолжается, пока не будут обработаны все три ряда массива ia. Инкремент ++p перемещает указатель p на следующий ряд (т.е. следующий элемент) массива ia.
Внутренний цикл for выводит значения внутренних массивов. Он начинается с создания указателя q на первый элемент в массиве, на который указывает указатель p. Результатом *p будет массив из четырех целых чисел. Как обычно, при использовании имени массива оно автоматически преобразуется в указатель на его первый элемент. Внутренний цикл for выполняется до тех пор, пока не будет обработан каждый элемент во внутреннем массиве. Чтобы получить указатель на элемент сразу за концом внутреннего массива, мы снова обращаемся к значению указателя p, чтобы получить указатель на первый элемент в этом массиве. Затем добавляем к нему 4, чтобы обработать четыре элемента в каждом внутреннем массиве.
Конечно, используя библиотечные функции begin() и end() (см. раздел 3.5.3), этот цикл можно существенно упростить:
// p указывает на первый массив в ia
for (auto p = begin(ia); p != end(ia); ++p) {
// q указывает на первый элемент во внутреннем массиве
for (auto q = begin(*p); q != end(*p); ++q)
cout << *q << ' '; // выводит значение, указываемое q
cout << endl;
}
Спецификатор auto позволяет библиотеке самостоятельно определить конечный указатель и избавить от необходимости писать тип, значение которого возвращает функция begin(). Во внешнем цикле этот тип — указатель на массив из четырех целых чисел. Во внутреннем цикле этот тип — указатель на тип int.
Псевдонимы типов упрощают указатели на многомерные массивы
Псевдоним типа (см. раздел 2.5.1) может еще больше облегчить чтение, написание и понимание указателей на многомерные массивы. Рассмотрим пример.
using int_array = int[4]Здесь и везде в оригинале именно adapt o r, а не adapt e r. — Примеч. ред .
; // объявление псевдонима типа нового стиля;
// см. раздел 2.5.1
typedef int int_array[4]Здесь и везде в оригинале именно adapt o r, а не adapt e r. — Примеч. ред .
; // эквивалентное объявление typedef;
// см. раздел 2.5.1
// вывести значение каждого элемента ia; каждый внутренний массив
// отображается в отдельной строке
for (int_array *p = ia; p != ia + 3; ++p) {
for (int *q = *p; q != *p + 4; ++q)
cout << *q << ' ';
cout << endl;
}
Код начинается с определения int_array как имени для типа "массив из четырех целых чисел". Это имя типа используется для определения управляющей переменной внешнего цикла for.
Упражнения раздела 3.6
Упражнение 3.43. Напишите три разных версии программы для вывода элементов массива ia. Одна версия должна использовать для управления перебором серийный оператор for, а другие две — обычный цикл for, но в одном случае использовать индексирование, а в другом — указатели. Во всех трех программах пишите все типы явно, т.е. не используйте псевдонимы типов и спецификаторы auto или decltype для упрощения кода.
Упражнение 3.44. Перепишите программы из предыдущего упражнения, используя псевдоним для типа управляющих переменных цикла.
Упражнение 3.45. Перепишите программы снова, на сей раз используя спецификатор auto.
Резюме
Одними из важнейших библиотечных типов являются vector и string. Строка — это последовательность символов переменной длины, а вектор — контейнер объектов единого типа.
Итераторы обеспечивают косвенный доступ к хранящимся в контейнере объектам. Итераторы используются для доступа и перемещения между элементами в строках и векторах.
Массивы и указатели на элементы массива обеспечивают низкоуровневые аналоги библиотечных типов vector и string. Как правило, предпочтительней использовать библиотечные классы, а не их низкоуровневые альтернативы, массивы и указатели, встроенные в язык.
Термины
Арифметические действия с итераторами (iterator arithmetic). Операции с итераторами векторов и строк. Добавление и вычитание целого числа из итератора приводит к изменению позиции итератора на соответствующее количество элементов вперед или назад от исходного. Вычитание двух итераторов позволяет вычислить дистанцию между ними. Арифметические действия допустимы лишь для итераторов, относящихся к элементам того же контейнера.
Арифметические действия с указателями (pointer arithmetic). Арифметические операции, допустимые для указателей. Указатели на массивы поддерживают те же операции, что и арифметические действия с итераторами.
Индекс (index). Значение, используемое в операторе индексирования для указания элемента, возвращаемого из строки, вектора или массива.
Инициализация значения (value initialization). Инициализация, в ходе которой объекты встроенного типа инициализируются нулем, а объекты класса — при помощи стандартного конструктора класса. Объекты типа класса могут быть инициализированы значением, только если у класса есть стандартный конструктор. Используется при инициализации элементов контейнера, когда указан его размер, но не указан инициализирующий элемент. Элементы инициализируются копией значения, созданного компилятором.
Инициализация копией (copy initialization). Форма инициализации, использующая знак =. Вновь созданный объект является копией предоставленного инициализатора.
Итератор после конца (off-the-end iterator). Итератор, возвращаемый функцией end(). Он указывает не на последний существующий элемент контейнера, а на позицию за его концом, т.е. на несуществующий элемент.
Контейнер (container). Тип, объекты которого способны содержать коллекцию объектов определенного типа. К контейнерным относится тип vector.
Объявлениеusing. Позволяет сделать имя, определенное в пространстве имен, доступным непосредственно в коде. using пространствоимен :: имя ; . Теперь имя можно использовать без префикса пространствоимен :: .
Оператор!. Оператор логического NOT. Возвращает инверсное значение своего операнда типа bool. Результат true, если операнд false, и наоборот.
Оператор&&. Оператор логического AND. Результат true, если оба операнда true. Правый операнд обрабатывается, только если левый операнд true.
Оператор[]. Оператор индексирования. Оператор obj[i] возвращает элемент в позиции i объекта контейнера obj. Счет индексов начинается с нуля: первый элемент имеет индекс 0, а последний — obj.size() - 1. Индексирование возвращает объект. Если p — указатель, a n — целое число, то p[n] является синонимом для *(p+n).
Оператор||. Оператор логического OR. Результат true, если любой операнд true. Правый операнд обрабатывается, только если левый операнд false.
Оператор++. Для типов итераторов и указателей определен оператор инкремента, который "добавляет единицу", перемещая итератор или указатель на следующий элемент.
Оператор<<. Библиотечный тип string определяет оператор вывода, читающий символы в строку.
Оператор->. Оператор стрелка. Объединяет оператор обращения к значению и точечный оператор: a->b — синоним для (*a).b.
Оператор>>. Библиотечный тип string определяет оператор ввода, читающий разграниченные пробелами последовательности символов и сохраняющий их в строковой переменной, указанной правым операндом.
Серийный операторfor (range for). Управляющий оператор, перебирающий значения указанной коллекции и выполняющий некую операцию с каждым из них.
Переполнение буфера (buffer overflow). Грубая ошибка программирования, результат использования индекса, выходящего из диапазона элементов контейнера, такого как string, vector или массив.
Прямая инициализация (direct initialization). Форма инициализации, не использующая знак =.
Расширение компилятора (compiler extension). Дополнительный компонент языка, предлагаемый некоторыми компиляторами. Код, применяющий расширение компилятора, может не подлежать переносу на другие компиляторы.
Создание экземпляра (instantiation). Процесс, в ходе которого компилятор создает специфический экземпляр шаблона класса или функции.
Строка в стиле С (C-style string). Символьный массив с нулевым символом в конце. Строковые литералы являются строками в стиле С. Строки в стиле С могут стать причиной ошибок.
Строка с завершающим нулевым символом (null-terminated string). Строка, последний символ которой сопровождается нулевым символом ('\0').
Типdifference_type. Целочисленный знаковый тип, определенный в классах vector и string, способный содержать дистанцию между любыми двумя итераторами.
Типiterator (итератор). Тип, используемый при переборе элементов контейнера и обращении к ним.
Типptrdiff_t. Машинозависимый знаковый целочисленный тип, определенный в заголовке cstddef. Является достаточно большим, чтобы содержать разницу между двумя указателями в самом большом массиве.
Типsize_t. Машинозависимый беззнаковый целочисленный тип, определенный в заголовке cstddef. Является достаточно большим, чтобы содержать размер самого большого возможного массива.
Типsize_type. Имя типа, определенного для классов vector и string, способного содержать размер любой строки или вектора соответственно. Библиотечные классы, определяющие тип size_type, относят его к типу unsigned.
Типstring. Библиотечный тип, представлявший последовательность символов.
Типvector. Библиотечный тип, содержащий коллекцию элементов определенного типа.
Функцияbegin(). Функция-член классов vector и string, возвращающая итератор на первый элемент. Кроме того, автономная библиотечная функция, получающая массив и возвращающая указатель на первый элемент в массиве.
Функцияempty(). Функция-член классов vector и string. Возвращает логическое значение (типа bool) true, если размер нулевой, или значение false в противном случае.
Функцияend(). Функция-член классов vector и string, возвращающая итератор на элемент после последнего элемента контейнера. Кроме того, автономная библиотечная функция, получающая массив и возвращающая указатель на элемент после последнего в массиве.
Функцияgetline(). Определенная в заголовке string функция, которой передают поток istream и строковую переменную. Функция читает данные из потока до тех пор, пока не встретится символ новой строки, а прочитанное сохраняет в строковой переменной. Функция возвращает поток istream. Символ новой строки в прочитанных данных отбрасывается.
Функцияpush_back(). Функция-член класса vector, добавляющая элементы в его конец.
Функцияsize(). Функция-член классов vector и string возвращает количество символов или элементов соответственно. Возвращаемое значение имеет тип size_type для данного типа.
Шаблон класса (class template). Проект, согласно которому может быть создано множество специализированных классов. Чтобы применить шаблон класса, необходимо указать дополнительную информацию. Например, чтобы определить вектор, указывают тип его элемента: vector
Глава 4
Выражения
Язык С++ предоставляет богатый набор операторов, а также определяет их назначение и применение к операндам встроенного типа. Он позволяет также определять назначение большинства операторов, операндами которых являются объекты классов. Эта глава посвящена операторам, определенным в самом языке и применяемым к операндам встроенных типов. Будут описаны также некоторые из операторов, определенных библиотекой. Определение операторов для собственных типов рассматривается в главе 14.
Выражение (expression) состоит из одного или нескольких операторов (operator) и возвращает результат (result) вычисления. Самая простая форма выражения — это одиночной литерал или переменная. Более сложные выражения формируются из оператора и одного или нескольких операндов (operand).
4.1. Основы
Существует несколько фундаментальных концепций, определяющих то, как обрабатываются выражения. Начнем с краткого обсуждении концепций, относящихся к большинству (если не ко всем) выражений. В последующих разделах эти темы рассматриваются подробней.
4.1.1. Фундаментальные концепции
Существуют унарные операторы (unary operator) и парные операторы (binary operator). Унарные операторы, такие как обращение к адресу (&) и обращение к значению (*), воздействуют на один операнд. Парные операторы, такие как равенство (==) и умножение (*), воздействуют на два операнда. Существует также (всего один) тройственный оператор (ternary operator), который использует три операнда, а также оператор вызова функции (function call), который получает неограниченное количество операндов.
Некоторые символы (symbol), например *, используются для обозначения как унарных (обращение к значению), так и парных (умножение) операторов. Представляет ли символ унарный оператор или парный, определяет контекст, в котором он используется. В использовании таких символов нет никакой взаимосвязи, поэтому их можно считать двумя разными символами.
Группировка операторов и операндов
Чтобы лучше понять порядок выполнения выражений с несколькими операторами, следует рассмотреть концепцию приоритета (precedence), порядка (associativity) и порядка вычисления (order of evaluation) операторов. Например, в следующем выражении используются сложение, умножение и деление:
5 + 10 * 20/2;
Операндами оператора * могли бы быть числа 10 и 20, либо 10 и 20/2, либо 15 и 20, либо 15 и 20/2. Понимание таких выражений и является темой следующего раздела.
Преобразование операндов
В ходе вычисления выражения операнды нередко преобразуются из одного типа в другой. Например, парные операторы обычно ожидают операндов одинакового типа. Но операторы применимы и к операндам с разными типами, если они допускают преобразование (см. раздел 2.1.2) в общий тип.
Хотя правила преобразования довольно сложны, по большей части они очевидны. Например, целое число можно преобразовать в число с плавающей запятой, и наоборот, но преобразовать тип указателя в число с плавающей точкой нельзя. Немного неочевидным может быть то, что операнды меньших целочисленных типов (например, bool, char, short и т.д.) обычно преобразуются (promotion) в больший целочисленный тип, как правило int. Более подробная информация о преобразованиях приведена в разделе 4.11.
Перегруженные операторы
Значение операторов для встроенных и составных типов определяет сам язык. Значение большинства операторов типов классов мы можем определить самостоятельно. Поскольку такие определения придают альтернативное значение существующему символу оператора, они называются перегруженными операторами (overloaded operator). Операторы >> и << библиотеки ввода и вывода, а также операторы, использовавшиеся с объектами строк, векторов и итераторов, являются перегруженными операторами.
При использовании перегруженного оператора его смысл, а также тип операндов и результата зависят от того, как определен оператор. Однако количество операндов, их приоритет и порядок не могут быть изменены.
#magnify.png L- и r-значения
Каждое выражение в языке С++ является либо r-значением (r-value), либо l-значением (l-value). Эти названия унаследованы от языка С и первоначально имели простую мнемоническую цель: l-значения могли стоять слева от оператора присвоения, а r-значения не могли.
В языке С++ различие не так просто. В языке С++ выражение l-значения возвращает объект или функцию. Однако некоторые l-значения, такие как константные объекты, не могут быть левым операндом присвоения. Кроме того, некоторые выражения возвращают объекты, но возвращают их как r-, а не l-значения. Короче говоря, при применении объекта в качестве r-значения используется его значение (т.е. его содержимое). При применении объекта в качестве l-значения используется его идентификатор (т.е. его область в памяти).
Операторы различаются по тому, требуют ли они операндов l- или r-значения, а также по тому, возвращают ли они l- или r-значения. Важный момент здесь в том, что (за одним исключением, рассматриваемым в разделе 13.6) l-значение можно использовать там, где требуется r-значение, однако нельзя использовать r-значение там, где требуется l-значение (т.е. область). Когда l-значение применяется вместо r-значения, используется содержимое объекта (его значение). Мы уже использовали несколько операторов, которые задействовали l-значения.
• В качестве своего левого операнда оператор присвоения требует (неконстантного) l-значения и возвращает свой левый операнд как l-значение.
• Оператор обращения к адресу (см. раздел 2.3.2) требует в качестве операнда l-значение и возвращает указатель на свой операнд как r-значение.
• Встроенные операторы обращения к значению и индексирования (см. раздел 2.3.2 и раздел 3.5.2), а также обращение к значению итератора и операторы индексирования строк и векторов (см. раздел 3.4.1, раздел 3.2.3 и раздел 3.3.3) возвращают l-значения.
• Операторы инкремента и декремента, как встроенные, так и итератора (см. раздел 1.4.1 и раздел 3.4.1), требуют l-значения в качестве операндов. Их префиксные версии (которые использовались до сих пор) также возвращают l-значения.
Рассматривая операторы, следует обратить внимание на то, должен ли операнд быть l-значением и возвращает ли он l-значение.
L- и r-значения также различаются при использовании спецификатора decltype (см. раздел 2.5.3). При применении спецификатора decltype к выражению (отличному от переменной) результатом будет ссылочный тип, если выражение возвращает l-значение. Предположим, например, что указатель p имеет тип int*. Поскольку обращение к значению возвращает l-значение, выражение decltype(*p) имеет тип int&. С другой стороны, поскольку оператор обращения к адресу возвращает r-значение, выражение decltype(&p) имеет тип int**, т.е. указатель на указатель на тип int.
4.1.2. Приоритет и порядок
Выражения с двумя или несколькими операторами называются составными (compound expression). Результат составного выражения определяет способ группировки операндов в отдельных операторах. Группировку операндов определяют приоритет и порядок. Таким образом, они определяют, какие части выражения будут операндами для каждого из операторов в выражении. При помощи скобок программисты могут изменять эти правила, обеспечивая необходимую группировку.
Обычно значение выражения зависит от того, как группируются его части. Операнды операторов с более высоким приоритетом группируются прежде операндов операторов с более низким приоритетом. Порядок определяет то, как группируются операнды с тем же приоритетом. Например, операторы умножения и деления имеют одинаковый приоритет относительно друг друга, но их приоритет выше, чем у операторов сложения и вычитания. Поэтому операнды операторов умножения и деления группируются прежде операндов операторов сложения и вычитания. Арифметические операторы имеют левосторонний порядок, т.е. они группируются слева направо.
• Благодаря приоритету результатом выражения 3 + 4*5 будет 23, а не 35.
• Благодаря порядку результатом выражения 20-15-3 будет 2, а не 8.
В более сложном примере при вычислении слева направо следующего выражения получается 20:
6 + 3 * 4 / 2 + 2
Вполне возможны и другие результаты: 9, 14 и 36. В языке С++ результат составит 14, поскольку это выражение эквивалентно следующему:
// здесь круглые скобки соответствуют стандартному приоритету и порядку
((6 + ((3 * 4) / 2)) + 2)
Круглые скобки переопределяют приоритет и порядок
Круглые скобки позволяют переопределить обычную группировку. Выражения в круглых скобках обрабатываются как отдельные модули, а во всех остальных случаях применяются обычные правила приоритета. Например, используя круглые скобки в предыдущем выражении, можно принудительно получить любой из четырех возможных вариантов:
// круглые скобки обеспечивают альтернативные группировки
cout << (6 + 3) * (4 / 2 + 2) << endl; // выводит 36
cout << ((6 + 3) * 4) / 2 + 2 << endl; // выводит 20
cout << 6 + 3 * 4 / (2 + 2) << endl; // выводит 9
#reader.png Когда важны приоритет и порядок
Мы уже видели примеры, где приоритет влияет на правильность наших программ. Рассмотрим обсуждавшийся в разделе 3.5.3 пример обращения к значению и арифметических действий с указателями.
int ia[] = {0,2,4,6,8}; // массив из 5 элементов типа int
int last = *(ia + 4); // ok: инициализирует last значением
// ia[4]Здесь и везде в оригинале именно adapt o r, а не adapt e r. — Примеч. ред .
, т.е. 8
last = *ia + 4; // last = 4, эквивалент ia[0] + 4
Если необходим доступ к элементу в области ia+4, то круглые скобки вокруг сложения необходимы. Без круглых скобок сначала группируется часть *ia, а к полученному значению добавляется 4.
Наиболее популярный случай, когда порядок имеет значение, — это выражения ввода и вывода. Как будет продемонстрировано в разделе 4.8, операторы ввода и вывода имеют левосторонний порядок. Этот порядок означает, что можно объединить несколько операций ввода и вывода в одном выражении.
cin >> v1 >> v2; // читать в v1, а затем в v2
В таблице раздела 4.12 перечислены все операторы, организованные по сегментам. У операторов в каждом сегменте одинаковый приоритет, причем сегменты с более высоким приоритетом расположены выше. Например, префиксный оператор инкремента и оператор обращения к значению имеют одинаковый приоритет, который выше, чем таковой у арифметических операторов. Таблица содержит ссылки на разделы, где описан каждый из операторов. Многие из этих операторов уже применялось, а большинство из остальных рассматривается в данной главе. Подробней некоторые из операторов рассматриваются позже.
Упражнения раздела 4.1.2
Упражнение 4.1. Какое значение возвратит выражение 5 + 10 * 20/2?
Упражнение 4.2. Используя таблицу раздела 4.12, расставьте скобки в следующих выражениях, чтобы обозначить порядок группировки операндов:
(а) * vec.begin() (b) * vec.begin() + 1
4.1.3. Порядок вычисления
Приоритет определяет группировку операндов. Но он ничего не говорит о порядке, в котором обрабатываются операнды. В большинстве случаев порядок не определен. В следующем выражении известно, что функции f1() и f2() будут вызваны перед умножением:
int i = f1() * f2();
В конце концов, умножаются именно их результаты. Тем не менее нет никакого способа узнать, будет ли функция f1() вызвана до функции f2(), или наоборот.
Для операторов, которые не определяют порядок вычисления, выражение, пытающееся обратиться к тому же объекту и изменить его, было бы ошибочным. Выражения, которые действительно так поступают, имеют непредсказуемое поведение (см. раздел 2.1.2). Вот простой пример: оператор << не дает никаких гарантий в том, как и когда обрабатываются его операнды. В результате следующее выражение вывода непредсказуемо:
int i = 0;
cout << i << " " << ++i << endl; // непредсказуемо
Непредсказуемость этой программы в том, что нет никакой возможности сделать выводы о ее поведении. Компилятор мог бы сначала обработать часть ++i, а затем часть i, тогда вывод будет 1 1. Но компилятор мог бы сначала обработать часть i, тогда вывод будет 0 1. Либо компилятор мог бы сделать что-то совсем другое. Поскольку у этого выражения неопределенное поведение, программа ошибочна, независимо от того, какой код создает компилятор.
Четыре оператора действительно гарантируют порядок обработки операндов. В разделе 3.2.3 упоминалось о том, что оператор логического AND (&&) гарантирует выполнение сначала левого операнда. Кроме того, он гарантирует, что правый операнд обрабатывается только при истинности левого операнда. Другими операторами, гарантирующими порядок обработки операндов, являются оператор логического OR (||) (раздел 4.3), условный оператор (? :) (раздел 4.7) и оператор запятая (,) (раздел 4.10).
#magnify.png Порядок вычисления, приоритет и порядок операторов
Порядок вычисления операндов не зависит от приоритета и порядка операторов. Рассмотрим следующее выражение:
f() + g() * h() + j()
• Приоритет гарантирует умножение результатов вызова функций g() и h().
• Порядок гарантирует добавление результата вызова функции f() к произведению g() и h(), а также добавление результата сложения к результату вызова функции j().
• Однако нет никаких гарантий относительно порядка вызова этих функций.
Если функции f(), g(), h() и j() являются независимыми и не влияют на состояние тех же объектов или выполняют ввод и вывод, то порядок их вызова несуществен. Но если любые из этих функций действительно воздействуют на тот же объект, то выражение ошибочно, а его поведение непредсказуемо.
Упражнения раздела 4.1.3
Упражнение 4.3. Порядок вычисления большинства парных операторов оставляется неопределенным, чтобы предоставить компилятору возможность для оптимизации. Эта стратегия является компромиссом между созданием эффективного кода и потенциальными проблемами в использовании языка программистом. Полагаете этот компромисс приемлемым? Кто-то да, кто- то нет.
Совет. Манипулирование составными выражениями
При написании составных выражений могут пригодиться два эмпирических правила.
1. В сомнительных случаях заключайте выражения в круглые скобки, чтобы явно сгруппировать операнды в соответствии с логикой программы.
2. При изменении значения операнда не используйте этот операнд в другом месте того же оператора.
Важнейшим исключением из второго правила является случай, когда часть выражения, изменяющая операнд, сама является операндом другой части выражения. Например, в выражении *++iter инкремент изменяет значение итератора iter , а измененное значение используется как операнд оператора * . В этом и подобных выражениях порядок обработки операндов не является проблемным. Но в больших выражениях те части, которые изменяют операнд, должны обрабатываться в первую очередь. Такой подход не создает никаких проблем и применяется достаточно часто.
4.2. Арифметические операторы
Таблица 4.1. Арифметические операторы
(левосторонний порядок)
Оператор | Действие | Применение |
+ | Унарный плюс | + выражение |
- | Унарный минус | - выражение |
* | Умножение | выражение * выражение |
/ | Деление | выражение / выражение |
% | Остаток | выражение % выражение |
+ | Сложение | выражение + выражение |
- | Вычитание | выражение - выражение |
В табл. 4.1 (и таблицах операторов последующих разделов) операторы сгруппированы по приоритету. Унарные арифметические операторы имеют более высокий приоритет, чем операторы умножения и деления, которые в свою очередь имеют более высокий приоритет, чем парные операторы вычитания и сложения. Операторы с более высоким приоритетом группируются перед операторами с более низким приоритетом. Все эти операторы имеют левосторонний порядок, т.е. при равенстве приоритетов они группируются слева направо.
Если не указано иное, то арифметические операторы могут быть применены к любому арифметическому типу (см. раздел 2.1.1) или любому типу, который может быть преобразован в арифметический тип. Операнды и результаты этих операторов являются r-значениями. Как упоминается в разделе 4.11, в ходе вычисления операторов их операнды малых целочисленных типов преобразуются в больший целочисленный тип и все операнды могут быть преобразованы в общий тип.
Унарные операторы плюс и минус могут быть также применены к указателям. Использование парных операторов + и - с указателями рассматривалось в разделе 3.5.3. Будучи примененным к указателю или арифметическому значению, унарный плюс возвращает (возможно, преобразованную) копию значения своего операнда.
Унарный оператор минус возвращает отрицательную копию (возможно, преобразованную) значения своего операнда.
int i = 1024;
int k = -i; // i равно -1024
bool b = true;
bool b2 = -b; // b2 равно true!
В разделе 2.1.1 упоминалось, что значения типа bool не нужно использовать для вычислений. Результат -b — хороший пример того, что имелось в виду.
Для большинства операторов операнды типа bool преобразуются в тип int. В данном случае значение переменной b, true, преобразуется в значение 1 типа int (см. раздел 2.1.2). Это (преобразованное) значение преобразуется в отрицательное, -1. Значение -1 преобразуется обратно в тип bool и используется для инициализации переменной b2. Поскольку значение инициализатора отлично от нуля, при преобразовании в тип bool его значением станет true. Таким образом, значением b2 будет true!
Внимание! Переполнение переменной и другие арифметические особенности
Некоторые арифметические выражения возвращают неопределенный результат. Некоторые из этих неопределенностей имеют математический характер, например деление на нуль. Причиной других являются особенности компьютеров, например, переполнение, происходящее при превышении вычисленным значением размера области памяти, представленной его типом.
Предположим, тип short занимает на машине 16 битов. В этом случае переменная типа short способна хранить максимум значение 32767. На такой машине следующий составной оператор присвоения приводит к переполнению.
short short_value = 32767; // максимальное значение при short 16 битов
short_value += 1; // переполнение
cout << "short_value: " << short_value << endl;
Результат присвоения 1 переменной short_value непредсказуем. Для хранения знакового значения 32768 требуется 17 битов, но доступно только 16. Многие системы никак не предупреждают о переполнении ни во время выполнения, ни во время компиляции. Подобно любой ситуации с неопределенностью, результат оказывается непредсказуем. На системе авторов программа завершилась с таким сообщением:
short value: -32768
Здесь произошло переполнение переменной: предназначенный для знака разряд содержал значение 0 , но был заменен на 1 , что привело к появлению отрицательного значения. На другой системе результат мог бы быть иным, либо программа могла бы повести себя по-другому, включая полный отказ.
Примененные к объектам арифметических типов, операторы +, -, * и / имеют вполне очевидные значения: сложение, вычитание, умножение и деление. Результатом деления целых чисел является целое число. Получаемая в результате деления дробная часть отбрасывается.
int ival1 = 21/6; // ival1 равно 3; результат усекается
// остаток отбрасывается
int ival2 = 21/7; // ival2 равно 3; остатка нет;
// результат - целочисленное значение
Оператор % известен как остаток (remainder), или оператор деления по модулю (modulus). Он позволяет вычислить остаток от деления левого операнда на правый. Его операнды должны иметь целочисленный тип.
int ival = 42;
double dval = 3.14;
ival % 12; // ok: возвращает 6
ival % dval; // ошибка: операнд с плавающей запятой
При делении отличное от нуля частное позитивно, если у операндов одинаковый знак, и отрицательное в противном случае. Прежние версии языка разрешали округление отрицательного частного вверх или вниз; однако новый стандарт требует округления частного до нуля (т.е. усечения).
Оператор деления по модулю определен так, что если m и n целые числа и n отлично от нуля, то (m/n)*n + m%n равно m. По определению, если m%n отлично от нуля, то у него тот же знак, что и у m. Прежние версии языка разрешали результату выражения m%n иметь тот же знак, что и у m, причем на реализациях, у которых отрицательный результат выражения m/n округлялся не до нуля, но такие реализации сейчас запрещены. Кроме того, за исключением сложного случая, где -m приводит к переполнению, (-m)/n и m/(-n) всегда эквивалентны -(m/n), m%(-n) эквивалентно m%n и (-m)%n эквивалентно -(m%n). А конкретно:
21 % 6; /* результат 3 */ 21 / 6; /* результат 3 */
21 % 7; /* результат 0 */ 21 / 7; /* результат 3 */
-21 % -8; /* результат -5 */ -21 / -8; /* результат 2 */
21 % -5; /* результат 1 */ 21 / -5; /* результат -4 */
Упражнения раздела 4.2
Упражнение 4.4. Расставьте скобки в следующем выражении так, чтобы продемонстрировать порядок его обработки. Проверьте свой ответ, откомпилировав и отобразив результат выражения без круглых скобок.
12 / 3 * 4 + 5 * 15 + 24 % 4 / 2
Упражнение 4.5. Определите результат следующих выражений:
(а) -30 * 3 + 21 / 5 (b) -30 + 3 * 21 / 5
(с) 30 / 3 * 21 % 5 (d) -30 / 3 * 21 % 4
Упражнение 4.6. Напишите выражение, чтобы определить, является ли значение типа int четным или нечетным.
Упражнение 4.7. Что значит переполнение? Представьте три выражения, приводящих к переполнению.
4.3. Логические операторы и операторы отношения
Операторам отношения передают операторы арифметического типа или типа указателя, а логическим операторам — операнды любого типа, допускающего преобразование в тип bool. Все они возвращают значение типа bool. Арифметические операнды и указатели со значением нуль рассматриваются как значение false, а все другие как значение true. Операнды для этих операторов являются r-значениями, а результат — r-значение.
Таблица 4.2. Логические операторы и операторы отношения
Порядок | Оператор | Действие | Применение |
Правосторонний | ! | Логическое NOT | ! выражение |
Левосторонний | < | Меньше | выражение < выражение |
Левосторонний | <= | Меньше или равно | выражение <= выражение |
Левосторонний | > | Больше | выражение > выражение |
Левосторонний | >= | Больше или равно | выражение >= выражение |
Левосторонний | == | Равно | выражение == выражение |
Левосторонний | != | Не равно | выражение != выражение |
Левосторонний | && | Логическое AND | выражение && выражение |
Левосторонний | || | Логическое OR | выражение || выражение |
Операторы логического AND и OR
Общим результатом оператора логического AND (&&) является true, если и только если оба его операнда рассматриваются как true. Оператор логического OR (||) возвращает значение true, если любой из его операндов рассматривается как true.
Операторы логического AND и OR всегда обрабатывают свой левый операнд перед правым. Кроме того, правый операнд обрабатывается, если и только если левый операнд не определил результат. Эта стратегия известна как вычисление по сокращенной схеме (short-circuit evaluation).
• Правая сторона оператора && вычисляется, если и только если левая сторона истинна.
• Правая сторона оператора || вычисляется, если и только если левая сторона ложна.
Оператор логического AND использовался в некоторых из программ главы 3. Эти программы использовали левый операнд для проверки, безопасно ли выполнять правый операнд. Например, условие цикла for в разд 3.2.3: сначала проверялось, что index не достиг конца строки:
index != s.size() && ! isspace(s[index])
Это гарантировало, что правый операнд не будет выполнен, если индекс уже вышел из диапазона.
Рассмотрим пример применения оператора логического OR. Предположим, что в векторе строк имеется некоторый текст, который необходимо вывести, добавляя символ новой строки после каждой пустой строки или после строки, завершающейся точкой. Для отображения каждого элемента используем серийный оператор for (раздел 3.2.3):
// обратите внимание, s - ссылка на константу; элементы не копируются и
// не могут быть изменены
for (const auto &s : text) { // для каждого элемента text
cout << s; // вывести текущий элемент
// пустые строки и строки, завершающиеся точкой, требуют новой строки
if (s.empty() || s[s.size() - 1] == '.')
cout << endl;
else
cout << " "; // в противном случае отделить пробелом
}
После вывода текущего элемента выясняется, есть ли необходимость выводить новую строку. Условие оператора if сначала проверяет, не пуста ли строка s. Если это так, то необходимо вывести новую строку независимо от значения правого операнда. Только если строка не пуста, обрабатывается второе выражение, которое проверяет, не заканчивается ли строка точкой. Это выражение полагается на вычисление по сокращенной схеме оператора ||, гарантирующего индексирование строки s, только если она не пуста.
Следует заметить, что переменная s объявлена как ссылка на константу (см. раздел 2.5.2). Элементами вектора text являются строки, и они могут быть очень большими, а использование ссылки позволяет избежать их копирования. Поскольку запись в элементы не нужна, объявляем s ссылкой на константу.
Оператор логического NOT
Оператор логического NOT (!) возвращает инверсию исходного значения своего операнда. Этот оператор уже использовался в разделе 3.2.2. В следующем примере подразумевается, что vec — это вектор целых чисел, для проверки наличия значений в элементах которого используется оператор логического NOT для значения, возвращенного функцией empty().
// отобразить первый элемент вектора vec, если он есть
if (!vec.empty())
cout << vec[0];
Подвыражение !vec.empty() возвращает значение true, если вызов функции empty() возвращает значение false.
Операторы отношения
Операторы отношения (<, <=, >, <=) имеют свой обычный смысл и возвращают значение типа bool. Эти операторы имеют левосторонний порядок.
Поскольку операторы отношения возвращают логическое значение, их сцепление может дать удивительный результат:
// Упс! это условие сравнивает k с результатом сравнения i < j
if (i < j < k) // true, если k больше 1!
Условие группирует i и j в первый оператор <. Результат этого выражения (типа bool) является левым операндом второго оператора <. Таким образом, переменная k сравнивается с результатом (true или false) первого оператора сравнения! Для реализации той проверки, которая и предполагалась, выражение нужно переписать следующим образом:
// условие истинно, если i меньше, чем j, и j меньше, чем k
if (i < j && j < k) { /* ... */ }
Проверка равенства и логические литералы
Если необходимо проверить истинность арифметического значения или объекта указателя, то самый простой способ подразумевает использование этого значения как условия.
if (val) { /* ... */ } // true, если val - любое не нулевое значение
if (!val) { /* ... */ } // true, если val - нуль
В обоих условиях компилятор преобразовывает val в тип bool. Первое условие истинно, пока значение переменной val отлично от нуля; второе истинно, если val — нуль.
Казалось бы, условие можно переписать так:
if (val == true) { /* ... */ } // true, только если val равно 1!
У этого подхода две проблемы. Прежде всего, он длинней и менее непосредствен, чем предыдущий код (хотя по общему признанию в начале изучения языка С++ этот код понятней). Но важней всего то, что если тип переменной val отличен от bool, то это сравнение работает не так, как ожидалось.
Если переменная val имеет тип, отличный от bool, то перед применением оператора == значение true преобразуется в тип переменной val. Таким образом, получается код, аналогичный следующему:
if (val == 1) { /*...*/ }
Как уже упоминалось, при преобразовании значения типа bool в другой арифметический тип false преобразуется в 0, a true — в 1 (см. раздел 2.1.2). Если бы нужно было действительно сравнить значение переменной val со значением 1, то условие так и следовало бы написать.
Использование логических литералов true и false в качестве операндов сравнения — обычно плохая идея. Эти литералы следует использовать только для сравнения с объектами типа bool.
Упражнения раздела 4.3
Упражнение 4.8. Объясните, когда обрабатываются операнды операторов логического AND, логического OR и оператора равенства.
Упражнение 4.9. Объясните поведение следующего условия оператора if:
const char *cp = "Hello World";
if (cp && *cp)
Упражнение 4.10. Напишите условие цикла while, который читал бы целые числа со стандартного устройства ввода, пока во вводе не встретится значение 42.
Упражнение 4.11. Напишите выражение, проверяющее четыре значения а, b, с и d и являющееся истинным, если значение а больше b, которое больше c, которое больше d.
Упражнение 4.12. С учетом того, что i, j и k имеют тип int, объясните значение выражения i != j < k.
4.4. Операторы присвоения
Левым операндом оператора присвоения должно быть допускающее изменение l-значение. Ниже приведено несколько примеров недопустимых попыток присвоения.
int i = 0, j = 0, k = 0; // инициализация, а не присвоение
const int ci = i; // инициализация, а не присвоение
1024 = k; // ошибка: литерал является r-значением
i + j = k; // ошибка: арифметическое выражение - тоже r-значение
ci = k; // ошибка: ci - константа (неизменяемое l-значение)
Результат присвоения, левый операнд, является l-значением. Тип результата совпадает с типом левого операнда. Если типы левого и правого операндов отличаются, тип правого операнда преобразуется в тип левого.
k = 0; // результат: тип int, значение 0
k = 3.14159; // результат: тип int, значение 3
По новому стандарту с правой стороны можно использовать список инициализации (см. раздел 2.2.1):
k = {3.14}; // ошибка: сужающее преобразование
vector
vi = {0,1,2,3,4,5,6,7,8,9}; // теперь vi содержит десять элементов
// со значениями от 0 до 9
Если левый операнд имеет встроенный тип, список инициализации может содержать максимум одно значение, и это значение не должно требовать сужающего преобразования (narrowing conversion) (см. раздел 2.2.1).
Для типов классов происходящее зависит от подробностей класса. В случае вектора шаблон vector определяет собственную версию оператора присвоения, позволяющего использовать список инициализации. Этот оператор заменяет элементы вектора с левой стороны элементами списка с правой.
Независимо от типа левого операнда список инициализации может быть пуст. В данном случае компилятор создает инициализированный значением по умолчанию (см. раздел 3.3.1) временный объект и присваивает это значение левому операнду.
Оператор присвоения имеет правосторонний порядок
В отличие от других парных операторов, присвоение имеет правосторонний порядок:
int ival, jval;
ival = jval = 0; // ok: каждой переменной присвоено значение 0
Поскольку присвоение имеет правосторонний порядок, его крайняя правая часть, jval = 0, является правым операндом крайнего левого оператора присвоения. Поскольку присвоение возвращает свой левый операнд, результат крайнего правого присвоения (т.е. jval) присваивается переменной ival.
Каждый объект в множественном операторе присвоения должен иметь тип, совпадающий с типом соседа справа, или допускать преобразование в него (раздел 4.11):
int ival, *pval; // ival имеет тип int; pval имеет тип указателя на int
ival = pval = 0; // ошибка: переменной типа int нельзя присвоить
// значение указателя
string s1, s2;
s1 = s2 = "OK"; // строковый литерал "OK" преобразован в строку
Первое присвоение некорректно, поскольку объекты ival и pval имеют разные типы и не существует преобразования типа int* (pval) в тип int (ival). Оно некорректно, несмотря на то, что значение нуль может быть присвоено любому объекту.
Второе присвоение, напротив, вполне допустимо. Строковый литерал преобразуется в значение типа string, которое и присваивается переменной s2 типа string. Результат этого присвоения — строка s2 — имеет тот же тип, что и строка s1.
Оператор присвоения имеет низкий приоритет
Присвоения нередко происходят в условиях. Поскольку оператор присвоения имеет относительно низкий приоритет, его обычно заключают в скобки, чтобы он работал правильно. Чтобы продемонстрировать, чем присвоение может быть полезно в условии, рассмотрим следующий цикл. Здесь необходимо вызывать функцию до тех пор, пока она не возвратит желаемое значение, скажем 42.
// подробный, а потому более подверженный ошибкам
// способ написания цикла
int i = get_value(); // получить первое значение
while (i != 42) {
// выполнить действия ...
i = get_value(); // получить остальные значения
}
Код начинается с вызова функции get_value(), затем следует цикл, условие которого использует значение, возвращенное этим вызовом. Последним оператором этого цикла является еще один вызов функции get_value(), далее цикл повторяется. Этот код можно переписать более непосредственно:
int i;
// лучший вариант цикла, теперь вполне понятно, что делает условие
while ((i = get_value()) != 42) {
// выполнить действия ...
}
Теперь условие вполне однозначно выражает намерение разработчика: необходимо продолжать, пока функция get_value() не возвратит значение 42. В ходе вычисления условия результат вызова функции get_value() присваивается переменной i, значение которой затем сравнивается со значением 42.
Без круглых скобок операндами оператора != было бы значение, возвращенное функцией get_value() и 42, а результат проверки (true или false) был бы присвоен переменной i, чего явно не планировалось!
Поскольку приоритет оператора присвоения ниже, чем у операторов отношения, круглые скобки вокруг присвоений в условиях обычно необходимы.
Не перепутайте операторы равенства и присвоения
Тот факт, что присвоение возможно в условии, зачастую имеет удивительные последствия:
if (i = j)
Условие оператора if присваивает значение переменной j переменной i, а затем проверяет результат присвоения. Если значение переменной j отлично от нуля, то условие истинно. Однако автор этого кода почти наверняка намеревался проверить равенство значений переменных i и j так:
if (i == j)
Ошибки такого рода хоть и известны, но трудны для обнаружения. Некоторые, но не все компиляторы достаточно "любезны", чтобы предупредить о таком коде, как в этом примере.
Составные операторы присвоения
Довольно нередки случаи, когда оператор применяется к объекту, а полученный результат повторно присваивается тому же объекту. В качестве примера рассмотрим программу из раздела 1.4.2:
int sum = 0;
// сложить числа от 1 до 10 включительно
for (int val = 1; val <= 10; ++val)
sum += val; // эквивалентно sum = sum + val
Подобный вид операций характерен не только для сложения, но и для других арифметических и побитовых операторов, которые рассматриваются в разделе 4.8. Соответствующие составные операторы присвоения (compound assignment) существуют для каждого из этих операторов.
+= -= *= /= %= // арифметические операторы
<<= >>= &= ^= |= // побитовые операторы; см. p. 4.8
Каждый составной оператор по сути эквивалентен обычному, за исключением того, что, когда используется составное присвоение, левый операнд обрабатывается (оценивается) только однажды.
Но эти формы имеют одно очень важное различие: в составном операторе присвоения левый операнд вычисляется только один раз. По существу, он эквивалентен следующему:
а = а оператор b;
Если используется обычное присвоение, операнд а обрабатывается дважды: один раз в выражении с правой стороны и во второй раз — как операнд слева. В подавляющем большинстве случаев это различие несущественно, возможно, кроме тех, где критически важна производительность.
Упражнения раздела 4.4
Упражнение 4.13. Каковы значения переменных i и d после каждого присвоения?
int i; double d;
(a) d = i = 3.5; (b) i = d = 3.5;
Упражнение 4.14. Объясните, что происходит в каждом из следующих операторов if?
if (42 = i) // ...
if (i = 42) // ...
Упражнение 4.15. Следующее присвоение недопустимо. Почему? Как исправить ситуацию?
double dval; int ival; int *pi;
dval = ival = pi = 0;
Упражнение 4.16. Хотя ниже приведены вполне допустимые выражения, их поведение может оказаться не таким, как предполагалось. Почему? Перепишите выражения так, чтобы они стали более понятными.
(a) if (p = getPtr() != 0)
(b) if (i = 1024)
4.5. Операторы инкремента и декремента
Операторы инкремента (++) и декремента (--) позволяют в краткой и удобной форме добавить или вычесть единицу из объекта. Эта форма записи обеспечивает не только удобство, она весьма популярна при работе с итераторами, поскольку большинство итераторов не поддерживает арифметических действий.
Эти операторы существуют в двух формах: префиксной и постфиксной. До сих пор использовался только префиксный оператор инкремента (prefix increment). Он осуществляет инкремент (или декремент) своего операнда и возвращает измененный объект как результат. Постфиксный оператор инкремента (postfix increment) (или декремента) возвращает копию первоначального операнда неизменной, а затем изменяет значение операнда.
int i = 0, j;
j = ++i; // j = 1, i = 1: префикс возвращает увеличенное значение
j = i++; // j = 1, i = 2: постфикс возвращает исходное значение
Операндами этих операторов должны быть l-значения. Префиксные операторы возвращают сам объект как l-значение. Постфиксные операторы возвращают копию исходного значения объекта как r-значение.
Совет. Используйте постфиксные операторы только по мере необходимости
Читатели с опытом языка С могли бы быть удивлены тем, что в написанных до сих пор программах использовался префиксный оператор инкремента. Причина проста: префиксная версия позволяет избежать ненужной работы. Она увеличивает значение и возвращает результат. Постфиксный оператор должен хранить исходное значение, чтобы возвратить неувеличенное значение как результат. Но если в исходном значении нет никакой потребности, то нет необходимости и в дополнительных действиях, осуществляемых постфиксным оператором.
Для переменных типа int и указателей компилятор способен оптимизировать код и уменьшить количество дополнительных действий. Для более сложных типов итераторов подобные дополнительные действия могут обойтись довольно дорого. При использовании префиксных версий об эффективности можно не волноваться. Кроме того, а возможно и важнее всего то, что так можно выразить свои намерения более непосредственно.
#magnify.png Объединение операторов обращения к значению и инкремента в одном выражении
Постфиксные версии операторов ++ и -- используются в случае, когда в одном составном выражении необходимо использовать текущее значение переменной, а затем увеличить его.
В качестве примера используем постфиксный оператор инкремента для написания цикла, выводящего значения вектора до, но не включая, первого отрицательного значения.
auto pbeg = v.begin();
// отображать элементы до первого отрицательного значения
while (pbeg != v.end() && *beg >= 0)
cout << *pbeg++ << endl; // отобразить текущее значение и
// переместить указатель pbeg
Выражение *pbeg++ обычно малопонятно новичкам в языках С++ и С. Но поскольку эта схема весьма распространена, программисты С++ должны понимать такие выражения.
Приоритет постфиксного оператора инкремента выше, чем оператора обращения к значению, поэтому код *pbeg++ эквивалентен коду *(pbeg++). Часть pbeg++ осуществляет инкремент указателя pbeg и возвращает как результат копию предыдущего значения указателя pbeg. Таким образом, операндом оператора * будет неувеличенное значение указателя pbeg. Следовательно, оператор выводит элемент, на который первоначально указывал указатель pbeg, а затем осуществляет его инкремент.
Этот подход основан на том, что постфиксный оператор инкремента возвращает копию своего исходного, не увеличенного операнда. Если бы он возвратил увеличенное значение, то обращение к элементу вектора по такому увеличенному значению привело бы к плачевным результатам: первым оказался бы незаписанный элемент вектора. Хуже того, если бы у последовательности не было никаких отрицательных значений, то в конце произошла бы попытка обращения к значению несуществующего элемента за концом вектора.
Совет. Краткость может быть достоинством
Такие выражения, как *iter++ , могут быть не очевидны, однако они весьма популярны. Следующая форма записи проще и менее подвержена ошибкам:
cout << *iter++ << endl;
чем ее более подробный эквивалент:
cout << *iter << endl;
++iter;
Поэтому примеры подобного кода имеет смысл внимательно изучать, чтобы они стали совершенно понятны. В большинстве программ С++ используются краткие формы выражений, а не их более подробные эквиваленты. Поэтому программистам С++ придется привыкать к ним. Кроме того, научившись работать с краткими формами, можно заметить, что они существенно менее подвержены ошибкам.
Помните, что операнды могут быть обработаны в любом порядке
Большинство операторов не гарантирует последовательности обработки операндов (см. раздел 4.1.3). Отсутствие гарантированного порядка зачастую не имеет значения. Это действительно имеет значение в случае, когда выражение одного операнда изменяет значение, используемое выражением другого. Поскольку операторы инкремента и декремента изменяют свои операнды, очень просто неправильно использовать эти операторы в составных выражениях.
Для иллюстрации проблемы перепишем цикл из раздела 3.4.1, который преобразует в верхний регистр символы первого введенного слова:
for (auto it = s.begin(); it != s.end() && !isspace(*it) ; ++it)
it = toupper(*it); // преобразовать в верхний регистр
Этот пример использует цикл for, позволяющий отделить оператор обращения к значению beg от оператора его приращения. Замена цикла for, казалось бы, эквивалентным циклом while дает неопределенные результаты:
// поведение следующего цикла неопределенно!
while (beg != s.end() && !isspace(*beg))
beg = toupper(*beg++); // ошибка: это присвоение неопределенно
Проблема пересмотренной версии в том, что левый и правый операнды оператора = используют значение, на которое указывает beg, и правый его изменяет. Поэтому присвоение неопределенно. Компилятор мог бы обработать это выражение так:
*beg = toupper(*beg); // сначала обрабатывается левая сторона
*(beg + 1) = toupper(*beg); // сначала обрабатывается правая сторона
Или любым другим способом.
Упражнения раздела 4.5
Упражнение 4.17. Объясните различие между префиксным и постфиксным инкрементом.
Упражнение 4.18. Что будет, если цикл while из последнего пункта этого раздела, используемый для отображения элементов вектора, задействует префиксный оператор инкремента?
Упражнение 4.19. С учетом того, что ptr указывает на тип int, vec — вектор vector
(a) ptr != 0 && *ptr++ (b) ival++ && ival
(с) vec[ival++] <= vec[ival]
4.6. Операторы доступа к членам
Операторы точка (.) (dot operator) (см. раздел 1.5.2) и стрелка (->) (arrow operator) (см. раздел 3.4.1) обеспечивают доступ к члену. Оператор точка выбирает член из объекта типа класса; оператор стрелка определен так, что код ptr -> mem эквивалентен коду (* ptr ). mem .
string s1 = "a string", *p = &s1;
auto n = s1.size(); // вызов функции-члена size() строки s1
n = (*p).size(); // вызов функции-члена size() объекта, на который
// указывает указатель p
n = p->size(); // эквивалент (*p).size()
Поскольку приоритет обращения к значению ниже, чем оператора точка, часть обращения к значению следует заключить в скобки. Если пропустить круглые скобки, этот код поведет себя совсем по-иному:
// вызов функции-члена size() объекта, на который указывает указатель p
// затем обращение к значению результата!
*p.size(); // ошибка: p - указатель, он не имеет функции-члена size()
Этот код пытается вызвать функцию-член size() объекта p. Однако p — это указатель, у которого нет никаких членов; этот код не будет откомпилирован.
Оператор стрелка получает операнд в виде указателя и возвращает l-значение. Оператор точка возвращает l-значение, если объект, член которого выбирается, является l-значением; в противном случае результат — r-значение.
Упражнения раздела 4.6
Упражнение 4.20. С учетом того, что iter имеет тип vector
(a) *iter++; (b) (*iter)++; (с) *iter.empty()
(d) iter->empty(); (e) ++*iter; (f) iter++->empty();
4.7. Условный оператор
Условный оператор (оператор ?:) (conditional operator) позволяет внедрять простые конструкции if...else непосредственно в выражение. Условный оператор имеет следующий синтаксис:
условие ? выражение1 : выражение2 ;
где условие — это выражение, используемое в качестве условия, а выражение1 и выражение2 — это выражения одинаковых типов (или типов, допускающих преобразование в общий тип). Эти выражения выполняются в зависимости от условия . Если условие истинно, то выполняется выражение1 ; в противном случае выполняется выражение2 . В качестве примера использования условного оператора рассмотрим код, определяющий, является ли оценка (grade) проходной (pass) или нет (fail):
string finalgrade = (grade < 60) ? "fail" : "pass";
Условие проверяет, не меньше ли оценка 60. Если это так, то результат выражения "fail"; в противном случае — результат "pass". Подобно операторам логического AND и OR (&& и ||), условный оператор гарантирует, что выполнено будет только одно из выражений, выражение1 или выражение2 .
Результат условного оператора — l-значение, если оба выражения l-значения или если они допускают преобразование в общий тип l-значения. В противном случае результат — r-значение.
Вложенные условные операторы
Один условный оператор можно вложить в другой. Таким образом, условный оператор применяются как условие или как один или оба выражения другого условного оператора. В качестве примера используем пару вложенных условных операторов для трехступенчатой проверки оценки, чтобы выяснить, является ли она выше проходной, просто проходной или непроходной.
finalgrade = (grade > 90) ? "high pass"
: (grade < 60) ? "fail" : "pass";
Первое условие проверяет, не выше ли оценка 90. Если это так, то выполняется выражение после ?, возвращающее литерал "high pass". Если условие ложно, выполняется ветвь :, которая сама является другим условным выражением. Это условное выражение проверяет, не меньше ли оценка 60. Если это так, то обрабатывается ветвь ?, возвращающая литерал "fail". В противном случае ветвь : возвращает литерал "pass".
Условный оператор имеет правосторонний порядок, т.е. его операнды группируются (как обычно) справа налево. Порядок объясняет тот факт, что правое условное выражение, сравнивающее grade со значением 60, образует ветвь : левого условного выражения.
Вложенные условные выражения быстро становятся нечитабельными, поэтому нежелательно создавать больше двух или трех вложений.
Применение условного оператора в выражении вывода
Условный оператор имеет довольно низкий приоритет. Когда условный оператор используется в большом выражении, его, как правило, следует заключать в круглые скобки. Например, условный оператор нередко используют для отображения одного из значений в зависимости от результата проверки условия. Отсутствие круглых скобок вокруг условного оператора в выражении вывода может привести к неожиданным результатам:
cout << ((grade < 60) ? "fail" : "pass"); // выводит pass или fail
cout << (grade < 60) ? "fail" : "pass"; // выводит 1 или 0!
cout << grade < 60 ? "fail" : "pass"; // ошибка: сравнивает cout с 60
Второе выражение использует сравнение grade и 60 как операнд оператора <<. В зависимости от истинности или ложности выражения grade < 60 выводится значение 1 или 0. Оператор << возвращает объект cout, который и проверяется в условии условного оператора. Таким образом, второе выражение эквивалентно следующему:
cout << (grade < 60); // выводит 1 или 0
cout ? "fail" : "pass"; // проверяет cout, а затем возвращает один из
// этих двух литералов в зависимости от
// истинности объекта cout
Последнее выражение ошибочно, поскольку оно эквивалентно следующему:
cout << grade; // приоритет оператора ниже, чем у
// сдвига, поэтому сначала выводится оценка,
cout < 60 ? "fail" : "pass"; // затем cout сравнивается с 60!
Упражнения раздела 4.7
Упражнение 4.21. Напишите программу, использующую условный оператор для поиска в векторе vector
Упражнение 4.22. Дополните программу, присваивающую переменной значение оценки (высокая, проходная, не проходная), еще одной оценки, минимально проходной, от 60 до 75 включительно. Напишите две версии: одна использует только условные операторы; вторая использует один или несколько операторов if. Как по вашему, какую версию проще понять и почему?
Упражнение 4.23. Следующее выражение не компилируется из-за приоритета операторов. Используя таблицу из раздела 4.12, объясните причину проблемы. Как ее исправить?
string s = "word";
string p1 = s + s[s.size() - 1] == 's' ? "" : "s" ;
Упражнение 4.24. Программа, различавшая проходную и непроходную оценку, зависела от того факта, что условный оператор имеет правосторонний порядок. Опишите, как обрабатывался бы этот оператор, имей он левосторонний порядок.
4.8. Побитовые операторы
Побитовые операторы (bitwise operator) получают операнды целочисленного типа, которые они используют как коллекции битов. Эти операторы позволяют проверять и устанавливать отдельные биты. Как будет описано в разделе 17.2, эти операторы можно также использовать для библиотечного типа bitset, представляющего коллекцию битов изменяемого размера.
Как обычно, если операнд — "малое целое число", его значение сначала преобразуется (раздел 4.11) в больший целочисленный тип. Операнды могут быть знаковыми или беззнаковыми.
Таблица 4.3. Побитовые операторы (левосторонний порядок)
Оператор | Действие | Применение |
~ | Побитовое NOT | ~ выражение |
<< | Сдвиг влево | выражение1 << выражение2 |
>> | Сдвиг вправо | выражение1 >> выражение2 |
& | Побитовое AND | выражение1 & выражение2 |
^ | Побитовое XOR | выражение1 ^ выражение2 |
| | Побитовое OR | выражение1 | выражение2 |
Если операнд знаковый и имеет отрицательное значение, то способ обработки "знакового разряда" большинства битовых операций зависит от конкретной машины. Кроме того, результат сдвига влево, изменяющего знаковый разряд, непредсказуем.
Поскольку нет никаких гарантий однозначного выполнения побитовых операторов со знаковыми переменными на разных машинах, настоятельно рекомендуется использовать в них только беззнаковые целочисленные значения.
Побитовые операторы сдвига
Мы уже использовали перегруженные версии операторов >> и <<, которые библиотека IO определяет для ввода и вывода. Однако первоначальное значение этих операторов — побитовый сдвиг операндов. Они возвращают значение, являющееся копией (возможно преобразованной) левого операнда, биты которого сдвинуты. Правый операнд не должен быть отрицательным, и его значение должно быть меньше количества битов результата. В противном случае операция имеет неопределенный результат. Биты сдвигаются влево (<<) или право (>>), при этом вышедшие за пределы биты отбрасываются.
Оператор сдвига влево (<<) (left-shift operator) добавляет нулевые биты справа. Поведение оператора сдвига вправо (>>) (right-shift operator) зависит от типа левого операнда: если он беззнаковый, то оператор добавляет слева нулевые биты; если он знаковый, то результат зависит от конкретной реализации: слева вставляются либо копии знакового разряда, либо нули.
В этих примерах подразумевается, что младший бит расположен справа, тип char содержит 8 битов, а тип int — 32 бита
// 0233 - восьмеричный литерал (см. раздел 2.1.3)
unsigned char bits = 0233; 1 0 0 1 1 0 1 1
bits << 8 // bits преобразуется в int и сдвигается влево на 8 битов
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1 1 0 1 1 0 0 0 0 0 0 0 0
bits << 31 // сдвиг влево на 31 бит отбрасывает крайние левые биты
1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
bits >> 3 // сдвиг вправо на 3 бита отбрасывает 3 крайних правых бита
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1 1
Побитовый оператор NOT
Побитовый оператор NOT (~) (bitwise NOT operator) создает новое значение с инвертированными битами своего операнда. Каждый бит, содержащий 1, превращается в 0; каждый бит, содержащий 0, — в 1.
unsigned char bits = 0227; 1 0 0 1 0 1 1 1
~bits
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 1 1 0 1 0 0 0
Здесь операнд типа char сначала преобразуется в тип int. Это оставляет значение неизменным, но добавляет нулевые биты в позиции старших разрядов. Таким образом, преобразование в тип int добавляет 24 бита старших разрядов, заполненных нулями. Биты преобразованного значения инвертируются.
Побитовые операторы AND, OR и XOR
Побитовые операторы AND (&), OR (|) и XOR (^) создают новые значения с битовым шаблоном, состоящим из двух их операндов.
unsigned char b1 = 0145; 0 1 1 0 0 1 0 1
unsigned char b2 = 0257; 1 0 1 0 1 1 1 1
b1 & b2 Все 24 старших бита 0 0 0 1 0 0 1 0 1
b1 | b2 Все 24 старших бита 0 1 1 1 0 1 1 1 1
b1 ^ b2 Все 24 старших бита 0 1 1 0 0 1 0 1 0
Каждая битовая позиция результата побитового оператора AND (&) содержит 1, если оба операнда содержат 1 в этой позиции; в противном случае результат — 0. У побитового оператора OR (|) бит содержит 1, если один или оба операнда содержат 1; в противном случае результат — 0. Для побитового оператора XOR (^) бит содержит 1, если любой, но не оба операнда содержат 1; в противном случае результат — 0.
Побитовые и логические (см. раздел 4.3) операторы нередко путают. Например, путают побитовый оператор & с логическим &&, побитовый | с логическим || и побитовый ~ с логическим !.
Использование побитовых операторов
Рассмотрим пример использования побитовых операторов. Предположим, что есть класс с 30 учениками. Каждую неделю класс отвечает на контрольные вопросы с оценкой "сдано/не сдано". Результаты всех контрольных записываются, по одному биту на ученика, чтобы представить успешную оценку или нет. Каждую контрольную можно представить в виде беззнакового целочисленного значения.
unsigned long quiz1 = 0; // это значение используется
// как коллекция битов
Переменная quiz1 определена как unsigned long. Таким образом, на любой машине она будет содержать по крайней мере 32 бита. Переменная quiz1 инициализируется явно, чтобы ее значение было определено изначально.
Учитель должен быть способен устанавливать и проверять отдельные биты. Например, должна быть возможность установить бит, соответствующий ученику номер 27, означающий, что этот ученик сдал контрольную. Чтобы указать, что ученик 27 прошел контрольную, создадим значение, у которого установлен только бит номер 27. Если затем применить побитовый оператор OR к этому значению и значению переменной quiz1, то все биты, кроме бита 27, останутся неизменными.
В данном примере счет битов переменной quiz1 начинается с 0, соответствующего младшему биту, 1 соответствует следующему биту и т.д.
Чтобы получить значение, означающее, что ученик 27 сдал контрольную, используется оператор сдвига влево и целочисленный литерал 1 типа unsigned long (см. раздел 2.1.3).
1UL << 27 // создает значение только с одним установленным битом
// в позиции 27
Первоначально переменная 1UL имеет 1 в самом младшем бите и по крайней мере 31 нулевой бит. Она определена как unsigned long, поскольку тип int гарантированно имеет только 16 битов, а необходимо по крайней мере 27. Это выражение сдвигает 1 на 27 битовых позиций, вставляя в биты позади 0.
К этому значению и значению переменной quiz1 применяется оператор OR. Поскольку необходимо изменить значение самой переменной quiz1, используем составной оператор присвоения (см. раздел 4.4):
quiz1 |= 1UL << 27; // указать, что ученик номер 27 сдал контрольную
Оператор |= выполняется аналогично оператору +=.
quiz1 = quiz1 | 1UL << 27; // эквивалент quiz1 |= 1UL << 21;
Предположим, что учитель пересмотрел контрольные и обнаружил, что ученик 27 фактически списал работу. Теперь учитель должен сбросить бит 27 в 0. На сей раз необходимо целое число, бит 27 которого сброшен, а все остальные установлены в 1. Применение побитового AND к этому значению и значению переменной quiz1 позволяет сбросить только данный бит:
quiz1 &= ~(1UL << 27); // ученик номер 27 не прошел контрольную
Мы получаем значение со всеми установленными битами, кроме бита 27, инвертируя предыдущее значение. У него все биты были сброшены в 0, кроме бита 27, который был установлен в 1. Применение побитового NOT к этому значению сбросит бит 27, а все другие установит. Применение побитового AND к этому значению и значению переменной quiz1 оставит неизменными все биты, кроме бита 27.
И наконец, можно узнать, как дела у ученика 27:
bool status = quiz1 & (1UL << 27); // как дела у ученика 27?
Здесь оператор AND применяется к значению с установленным битом 27 и значением переменной quiz1. Результат отличен от нуля (т.е. истинен), если бит 27 в значении переменной quiz1 установлен; в противном случае он нулевой.
#reader.png Операторы сдвига (они же ввода и вывода) имеют левосторонний порядок
Хотя многие программисты никогда не используют побитовые операторы непосредственно, почти все они использует их перегруженные версии в виде операторов ввода и вывода. Перегруженный оператор имеет тот же приоритет и порядок, что и его встроенная версия. Поэтому программисты должны иметь понятие о приоритете и порядке операторов сдвига, даже если они никогда не используют их встроенные версии.
Поскольку операторы сдвига имеют левосторонний порядок, выражение
cout << "hi" << " there" << endl;
выполняется так:
( (cout << "hi") << " there" ) << endl;
В этом операторе операнд "hi" группируется с первым символом <<. Его результат группируется со вторым, а его результат с третьим символом.
Приоритет операторов сдвига средний: ниже, чем у арифметических операторов, но выше, чем у операторов отношения, присвоения и условных операторов. Эти различия в уровнях приоритета свидетельствуют о том, что для правильной группировки операторов с более низким приоритетом следует использовать круглые скобки.
cout << 42 + 10; // ok: приоритет + выше, поэтому выводится сумма
cout << (10 < 42); // ok: группировку определяют скобки; выводится 1
cout << 10 < 42; // ошибка: попытка сравнить cout с 42!
Последний оператор cout интерпретируется так
(cout << 10) < 42;
Он гласит: "записать 10 в поток cout, а затем сравнить результат (т.е. поток cout) со значением 42".
Упражнения раздела 4.8
Упражнение 4.25. Каково значение выражения ~'q' << 6 на машине с 32-битовыми целыми числами и 8-битовыми символами, с учетом, что символ 'q' имеет битовое представление 01110001?
Упражнение 4.26. Что будет, если в приведенном выше примере оценки учеников использовать для переменной quiz1 тип unsigned int?
Упражнение 4.27. Каков результат каждого из этих выражений?
unsigned long ul1 = 3, ul2 = 7;
(a) ul1 & ul2 (b) ul1 | ul2
(c) ul1 && ul2 (d) ul1 || ul2
4.9. Оператор
sizeof
Оператор sizeof возвращает размер в байтах результата выражения или указанного по имени типа. Оператор имеет правосторонний порядок. Результат оператора sizeof — это константное выражение (см. раздел 2.4.4) типа size_t (см. раздел 3.5.2). Оператор существует в двух формах.
sizeof( тип )
sizeof выражение
Во второй форме оператор sizeof возвращает размер типа, возвращаемого выражением. Оператор sizeof необычен тем, что он не выполняет свой операнд.
Sales_data data, *p;
sizeof(Sales_data); // размер, необходимый для хранения объекта
// типа Sales_item
sizeof data; // размер типа данных, аналог sizeof(Sales_data)
sizeof p; // размер указателя
sizeof *p; // размер типа, на который указывает указатель p,
// т.е. sizeof(Sales_data)
sizeof data.revenue; // размер типа члена revenue класса Sales_data
sizeof Sales_data::revenue; // альтернативный способ получения
// размера revenue
Наиболее интересен пример sizeof *p. Во-первых, поскольку оператор sizeof имеет правосторонний порядок и тот же приоритет, что и оператор *, это выражение группируется справа налево. Таким образом, оно эквивалентно выражению sizeof(*p). Во-вторых, поскольку оператор sizeof не выполняет свой операнд, не имеет значения, допустим ли указатель p (т.е. инициализирован ли он) (см. раздел 2.3.2). Обращения к значению недопустимого указателя оператор sizeof не осуществляет, и указатель фактически не используется, поэтому он безопасен. Ему и не нужно обращаться к значению указателя, чтобы выяснить, какой тип он возвратит.
По новому стандарту для доступа к члену класса при получении его размера можно использовать оператор области видимости. Обычно к членам класса можно обратиться только через объект этого класса. Больше не обязательно предоставлять объект, так как оператор sizeof не обязан выбирать член класса, чтобы узнать его размер.
Результат применения оператора sizeof частично зависит от типа, к которому он применен.
• Если это тип char или выражения, результат которого имеет тип char, то это гарантированно будет 1.
• Если это ссылка, то возвращает размер типа объекта, на который она ссылается.
• Если это указатель, то возвращается размер, необходимый для хранения указателя.
• Если это обращение к значению указателя, то возвращается размер типа объекта, на который он указывает, вне зависимости от его допустимости.
• Если это массив, то возвращается размер всего массива. Это эквивалентно получению размера элемента массива и его умножению на количество элементов. Обратите внимание, что оператор sizeof не преобразует массив в указатель.
• Если это строка или вектор, то возвращается размер только фиксированной части этих типов; но не размер, используемый элементами объекта.
Поскольку оператор sizeof возвращает размер всего массива, разделив размер массива на размер элемента, можно определить количество элементов в массиве:
// sizeof(ia)/sizeof(*ia) возвращает количество элементов в ia
constexpr size_t sz = sizeof (ia)/sizeof(*ia);
int arr2[sz]; // ok: sizeof возвращает константное выражение
// (p. 2.4.4)
Так как оператор sizeof возвращает константное выражение, его результат можно использовать в выражении для определения размерности массива.
Упражнения раздела 4.9
Упражнение 4.28. Напишите программу для вывода размера каждого из встроенных типов.
Упражнение 4.29. Предскажите вывод следующего кода и объясните свое рассуждение. Напишите и выполните соответствующую программу. Совпадает ли вывод с ожиданиями? Если нет, то объясните почему.
int x[10]; int *p = x;
cout << sizeof(x)/sizeof(*x) << endl;
cout << sizeof(p)/sizeof(*p) << endl;
Упражнение 4.30. Используя таблицу из раздела 4.12, расставьте скобки в следующих выражениях так, чтобы продемонстрировать порядок его обработки:
(a) sizeof x + y (b) sizeof p->mem[i]
(с) sizeof а < b (d) sizeof f()
4.10. Оператор запятая
Оператор запятая (,) (comma operator) получает два операнда, обрабатываемых слева направо. Подобно операторам логического AND и OR, а также условному оператору, оператор запятая гарантирует порядок обработки своих операндов.
Левое выражение обрабатывается, а его результат отбрасывается. Результат выражения запятая — это значение правого выражения. Результат является l-значением, если правый операнд — l-значение.
Оператор запятая нередко используется в цикле for:
vector
// присвоить значения элементам size...1 вектора ivec
for (vector
ix != ivec.size(); ++ix, --cnt)
ivec[ix] = cnt;
Здесь выражения в заголовке цикла for увеличивают значение итератора ix и уменьшают значение целочисленной переменной cnt. Значения итератора ix и переменной cnt изменяются при каждой итерации цикла. Пока проверка итератора ix проходит успешно, следующему элементу присваивается текущее значение переменной cnt.
Упражнения раздела 4.10
Упражнение 4.31. Программа этого раздела использовала префиксные операторы инкремента и декремента. Объясните, почему были использованы префиксные, а не постфиксные версии? Что следует изменить для использования постфиксных версий? Перепишите программу с использованием постфиксных операторов.
Упражнение 4.32. Объясните следующий цикл:
constexpr int size = 5;
int ia[size] = {1,2,3,4,5};
for (int *ptr = ia, ix = 0;
ix != size && ptr != ia+size; ++ix, ++ptr) { /* ... */ }
Упражнение 4.33. Используя таблицу раздела 4.12, объясните, что делает следующее выражение:
someValue ? ++x, ++y : --x, --y
4.11. Преобразование типов
В языке С++ некоторые типы взаимосвязаны. Когда два типа взаимосвязаны, объект или значение одного типа можно использовать там, где ожидается операнд связанного типа. Два типа считаются связанными, если между ними возможно преобразование (conversion).
Для примера рассмотрим следующее выражение, инициализирующее переменную ival значением 6:
int ival = 3.541 + 3; // компилятор может предупредить о потере точности
Операндами сложения являются значения двух разных типов: 3.541 имеет тип double а 3 — int. Вместо попытки суммирования двух значений разных типов язык С++ определяет набор преобразований, позволяющих преобразовать операнды в общий тип. Эти преобразования выполняются автоматически без вмешательства программиста, а иногда и без его ведома. Поэтому они и называются неявным преобразованием (implicit conversion).
Неявные преобразования между арифметическими типами определены так, чтобы по возможности сохранять точность. Как правило, если у выражения есть и целочисленное значение, и значение с плавающей запятой, целое число преобразуется в число с плавающей точкой. В данном случае значение 3 преобразуется в тип double, осуществляется сложение чисел с плавающей запятой, и возвращается результат типа double.
Затем происходит инициализация. При инициализации доминирует тип инициализируемого объекта. Поэтому инициализатор преобразуется в его тип. В данном случае результат сложения типа double преобразуется в тип int и используется для инициализации переменной ival. Преобразование типа double в тип int усекает значение типа double, отбрасывая десятичную часть. В данном случае выражение присваивает переменной ival значение 6.
Когда происходят неявные преобразования
Компилятор автоматически преобразует операнды при следующих обстоятельствах.
• В большинстве выражений значения целочисленных типов, меньших, чем int, сначала преобразуются в соответствующий больший целочисленный тип.
• В условиях нелогические выражения преобразуются в тип bool.
• При инициализации инициализатор преобразуется в тип переменной; при присвоении правый операнд преобразуется в тип левого.
• В арифметических выражениях и выражениях отношения с операндами смешанных типов происходит преобразование в общий тип.
• Преобразования происходят также при вызове функций, как будет продемонстрировано в главе 6.
4.11.1. Арифметические преобразования
Арифметические преобразования (arithmetic conversion), впервые представленные в разделе 2.1.2, преобразуют один арифметический тип в другой. Иерархию преобразований типов определяют правила, согласно которым операнды операторов преобразуются в самый большой общий тип. Например, если один операнд имеет тип long double, то второй операнд преобразуется тоже в тип long double независимо от своего типа. Короче говоря, в выражениях, где используются целочисленные значения и значения с плавающей точкой, целочисленное значение преобразуется в соответствующий тип с плавающей точкой.
Целочисленные преобразования
Целочисленное преобразование (integral promotion) преобразовывает значения малых целочисленных типов в большие. Типы bool, char, signed char, unsigned char, short и unsigned short преобразуются в int, если значение соответствует ему, а в противном случае оно преобразуется в тип unsigned int. Как уже неоднократно упоминалось, значение false типа bool преобразуется в 0, a true в 1.
Большие символьные типы (wchar_t, char16_t и char32_t) преобразуются в наименьший целочисленный тип int, unsigned int, long, unsigned long, long long или unsigned long long, которому соответствуют все возможные значения этого символьного типа.
Операнды беззнакового типа
Если операнды оператора имеют разные типы, они обычно преобразуются в общий тип. Если любой из операндов имеет беззнаковый тип, то тип, в который преобразуются операнды, зависит от относительных размеров целочисленных типов на машине.
Как обычно, сначала осуществляются целочисленные преобразования. Если полученные в результате типы совпадают, то никаких дальнейших преобразований не нужно. Если оба (возможно преобразованных) операнда имеют одинаковый знак, то операнд с меньшим типом преобразуется в больший тип.
При разных знаках, если тип беззнакового операнда больший, чем у знакового операнда, знаковый операнд преобразуется в беззнаковый. Например, при операторах типа unsigned int и int, int преобразуется в unsigned int. Следует заметить, что если значение типа int отрицательное, результат преобразуется так, как описано в разделе 2.1.2.
Остается случай, когда тип знакового операнда больше, чем беззнакового. В данном случае результат зависит от конкретной машины. Если все значения беззнакового типа соответствуют большему типу, то операнд беззнакового типа преобразуется в знаковый. Если значения не соответствуют, то знаковый операнд преобразуется в беззнаковый. Например, если операнды имеют типы long и unsigned int и размер у них одинаковый, операнд типа long будет преобразован в unsigned int. Если тип long окажется больше, то unsigned int будет преобразован в long.
Концепция арифметических преобразований
Арифметические преобразования проще всего изучить на примерах.
bool flag; char cval;
short sval; unsigned short usval;
int ival; unsigned int uival;
long lval; unsigned long ulval;
float fval; double dval;
3.14159L + 'a'; // 'a' преобразуется в int, а затем int в long double
dval + ival; // ival преобразуется в double
dval + fval; // fval преобразуется в double
ival = dval; // dval преобразуется в int (с усечением)
flag = dval; // если dval - 0, flag - false, в противном случае - true
cval + fval; // cval преобразуется в int, затем int во float
sval + cval; // sval и cval преобразуется в int
cval + lval; // cval преобразуется в long
ival + ulval; // ival преобразуется в unsigned long
usval + ival; // преобразование зависит от соотношения
// размеров типов unsigned short и int
uival + lval; // преобразование зависит от соотношения
// размеров типов unsigned int и long
В первом выражении суммы символьная константа 'a' имеет тип char, являющийся числовым (см. раздел 2.1.1). Какое именно это значение, зависит от используемого машиной набора символов. На машине авторов, где установлен набор символов ASCII, символу 'a' соответствует число 97. При добавлении символа 'a' к значению типа long double значение типа char преобразуется в тип int, а затем в тип long double. Это преобразованное значение добавляется к литералу. Интересны также два последних случая, где происходит преобразование беззнаковых значений. Тип результата этих выражений зависит от конкретной машины.
Упражнения раздела 4.11.1
Упражнение 4.34. С учетом определений переменных данного раздела объясните, какие преобразования имеют место в следующих выражениях:
(a) if (fval) (b) dval = fval + ival; (c) dval + ival * cval;
Помните, что возможно придется учитывать порядок операторов.
Упражнение 4.35. С учетом определений
char cval; int ival; unsigned int ui;
float fval; double dval;
укажите неявные преобразования типов, если таковые вообще имеются.
(a) cval = 'a' + 3; (b) fval = ui - ival * 1.0;
(с) dval = ui * fval; (d) cval = ival + fval + dval;
4.11.2. Другие неявные преобразования
Кроме арифметических, существует еще несколько видов неявных преобразований, включая следующие.
Преобразование массива в указатель. В большинстве выражений, когда используется массив, он автоматически преобразуется в указатель на свой первый элемент.
int ia[10]; // массив из десяти целых чисел
int* ip = ia; // ia преобразуется в указатель на первый элемент
Это преобразование не происходит при использовании массива в выражении decltype или в качестве операнда операторов обращения к адресу (&), sizeof или typeid (который рассматривается в разделе 19.2.2). Преобразование не происходит также при инициализации ссылки на массив (см. раздел 3.5.1). Подобное преобразование указателя происходит при использовании в выражении типа функции, как будет описано в разделе 6.7.
Преобразование указателя. Существует несколько других преобразований указателя: постоянное целочисленное значение 0 и литерал nullptr могут быть преобразованы в указатель на любой тип; указатель на любой неконстантный тип может быть преобразован в void*, а указатель на любой тип может быть преобразован в const void*. Как будет продемонстрировано в разделе 15.2.2, существуют дополнительные преобразования указателя, относящиеся к типам, связанным наследованием.
Преобразование в тип bool . Существует автоматическое преобразование арифметических типов и типов указателя в тип bool. Если указатель или арифметическое значение — нуль, преобразование возвращает значение false; любое другое значение возвращает true:
char *cp = get_string();
if (cp) /* ... */ // true, если cp не нулевой указатель
while (*cp) /* ... */ // true, если *cp не нулевой символ
Преобразование в константу. Указатель на неконстантный тип можно преобразовать в указатель на соответствующий константный тип, то же относится и к ссылкам. Таким образом, если Т — тип, то указатель или ссылку на тип T можно преобразовать в указатель или ссылку на const Т (см. разделы 2.4.1 и 2.4.2).
int i;
const int &j = i; // преобразовать в ссылку на const int
const int *p = &i; // преобразовать неконстантный адрес в константный
int &r = j, *q = p; // ошибка: преобразование константы в не константу
// недопустимо
Обратное преобразование (устранение спецификатора const нижнего уровня) невозможно.
Преобразование, определенное типами класса. Тип класса может сам определять преобразования, которые компилятор применит автоматически. Компилятор применяет только одно преобразование типа класса за раз. В разделе 7.5.4 приведен пример, когда необходимо несколько преобразований, и он не работает.
В программах ранее уже использовались преобразования типов класса, когда символьная строка в стиле С использовалась там, где ожидался библиотечный тип string (см. раздел 3.5.5), а также при чтении из потока istream в условии.
string s, t = "a value"; // с имвольный строковый литерал преобразован
// в тип string
while (cin >> s) // условие while преобразует cin в bool
Условие (cin >> s) читает поток cin и возвращает его же как результат. Условия ожидают значение типа bool, но оно проверяет значение типа istream. Библиотека IO определяет преобразование из типа istream в bool. Это преобразование используется автоматически, чтобы преобразовать поток cin в тип bool. Результирующее значение типа bool зависит от состояния потока. Если последнее чтение успешно, то преобразование возвращает значение true. Если последняя попытка потерпела неудачу, то преобразование возвращает значение false.
4.11.3. Явные преобразования
Иногда необходимо явно преобразовать объект в другой тип. Например, в следующем коде может понадобиться использование деления с плавающей точкой:
int i, j;
double slope = i/j;
Для этого необходим способ явного преобразования переменных i и/или j в тип double. Для явного преобразования используется приведение (cast) типов.
Хотя приведение время от времени необходимо, оно довольно опасно.
Именованные операторы приведения
Именованный оператор приведения имеет следующую форму:
имя_приведения < тип >( выражение );
где тип — это результирующий тип преобразования, а выражение — приводимое значение. Если тип — ссылка, то результат l-значение. Имя_приведения может быть одним из следующих: static_cast, dynamic_cast, const_cast и reinterpret_cast. Приведение dynamic_cast, обеспечивающее идентификацию типов времени выполнения, рассматривается в разделе 19.2. Имя_приведения определяет, какое преобразование осуществляется.
Оператор static_cast
Любое стандартное преобразование типов, кроме задействующего спецификатор const нижнего уровня, можно затребовать, используя оператор static_cast. Например, приведя тип одного из операндов к типу double, можно заставить выражение использовать деление с плавающей точкой:
// приведение для вынужденного деления с плавающей точкой
double slope = static_cast
Оператор static_cast зачастую полезен при присвоении значения большего арифметического типа переменной меньшего. Приведение сообщает и читателю программы, и компилятору, что мы знаем и не беспокоимся о возможной потере точности. При присвоении большего арифметического типа меньшему компиляторы зачастую выдают предупреждение. При явном приведении предупреждающее сообщение не выдается.
Оператор static_cast полезен также при выполнении преобразований, которые компилятор не выполняет автоматически. Например, его можно использовать для получения значения указателя, сохраняемого в указателе void* (см. раздел 2.3.2):
void* p = &d; // ok: адрес любого неконстантного объекта может
// храниться в указателе void*
// ok: преобразование void* назад в исходный тип указателя
double *dp = static_cast
После сохранения адреса в указателе типа void* можно впоследствии использовать оператор static_cast и привести указатель к его исходному типу, что позволит сохранить значение указателя. Таким образом, результат приведения будет равен первоначальному значению адреса. Однако следует быть абсолютно уверенным в том, что тип, к которому приводится указатель, является фактическим типом этого указателя; при несоответствии типов результат непредсказуем.
Оператор const_cast
Оператор const_cast изменяет только спецификатор const нижнего уровня своего операнда (см. раздел 2.4.3):
const char *pc;
char *p = const_cast
// указателя непредсказуема
Принято говорить, что приведение, преобразующее константный объект в неконстантный, "сбрасывает const". При сбросе константности объекта компилятор больше не будет препятствовать записи в этот объект. Если объект первоначально не был константным, использование приведения для доступа на запись вполне допустимо. Но применение оператора const_cast для записи в первоначально константный объект непредсказуемо.
Только оператор const_cast позволяет изменить константность выражения. Попытка изменить константность выражения при помощи любого другого именованного оператора приведения закончится ошибкой компиляции. Аналогично нельзя использовать оператор const_cast для изменения типа выражения:
const char *cp;
// ошибка: static_cast не может сбросить const
char *q = static_cast
static_cast
const_cast
// константность
Оператор const_cast особенно полезен в контексте перегруженных функций, рассматриваемых в разделе 6.4.
Оператор reinterpret_cast
Оператор reinterpret_cast осуществляет низкоуровневую интерпретацию битовой схемы своих операндов. Рассмотрим, например, следующее приведение:
int *ip;
char *pc = reinterpret_cast
Никогда не следует забывать, что фактическим объектом, на который указывает указатель pc, является целое число, а не символ. Любое использование указателя pc, подразумевающее, что это обычный символьный указатель, вероятно, потерпит неудачу во время выполнения. Например, следующий код, вероятней всего, приведет к непредвиденному поведению во время выполнения:
string str(pc);
Использование указателя pc для инициализации объекта типа string — хороший пример небезопасности оператора reinterpret_cast. Проблема в том, что при изменении типа компилятор не выдаст никаких предупреждений или сообщений об ошибке. При инициализации указателя pc адресом типа int компилятор не выдаст ни предупреждения, ни сообщения об ошибке, поскольку явно указано, что это и нужно. Однако любое последующее применение указателя pc подразумевает, что он содержит адрес значения типа char*. Компилятор не способен выяснить, что фактически это указатель на тип int. Таким образом, инициализация строки str при помощи указателя pc вполне правомерна, хотя в данном случае абсолютно бессмысленна, если не хуже! Отследить причину такой проблемы иногда чрезвычайно трудно, особенно если приведение указателя ip к pc происходит в одном файле, а использование указателя pc для инициализации объекта класса string — в другом.
Оператор reinterpret_cast жестко зависит от конкретной машины. Чтобы безопасно использовать оператор reinterpret_cast, следует хорошо понимать, как именно реализованы используемые типы, а также то, как компилятор осуществляет приведение.
Приведение типов в старом стиле
В ранних версиях языка С++ явное приведение имело одну из следующих двух форм:
тип ( выражение ); // форма записи приведения в стиле функции
( тип ) выражение ; // форма записи приведения в стиле языка С
В зависимости от используемых типов, приведение старого стиля срабатывает аналогично операторам const_cast, static_cast или reinterpret_cast. В случаях, где используются операторы static_cast или const_cast, приведение типов в старом стиле позволяет осуществить аналогичное преобразование, что и соответствующий именованный оператор приведения. Но если ни один из подходов не допустим, то приведение старого стиля срабатывает аналогично оператору reinterpret_cast. Например, используя форму записи старого стиля, можно получить тот же результат, что и с использованием reinterpret_cast.
char *pc = (char*) ip; // ip указатель на тип int
Совет. Избегайте приведения типов
Приведение нарушает обычный порядок контроля соответствия типов (см. раздел 2.2), поэтому авторы настоятельно рекомендуют избегать приведения типов. Это особенно справедливо для оператора reinterpret_cast . Такие приведения всегда опасны. Операторы const_cast могут быть весьма полезны в контексте перегруженных функций, рассматриваемых в разделе 6.4. Использование оператора const_cast зачастую свидетельствует о плохом проекте. Другие операторы приведения, static_cast и dynamic_cast , должны быть необходимы нечасто. При каждом применении приведения имеет смысл хорошо подумать, а нельзя ли получить тот же результат другим способом. Если приведение все же неизбежно, имеет смысл принять меры, позволяющие снизить вероятность возникновения ошибки, т.е. ограничить область видимости, в которой используется приведенное значение, а также хорошо документировать все подобные случаи.
Приведения старого стиля менее очевидны, чем именованные операторы приведения. Поскольку их легко упустить из виду, обнаружить ошибку становится еще трудней.
Упражнения раздела 4.11.3
Упражнение 4.36. С учетом того, что i имеет тип int, a d — double, напишите выражение i *= d так, чтобы осуществлялось целочисленное умножение, а не с плавающей запятой.
Упражнение 4.37. Перепишите каждое из следующих приведений старого стиля так, чтобы использовался именованный оператор приведения.
int i; double d; const string *ps; char *pc; void *pv;
(a) pv = (void*)ps; (b) i = int(*pc);
(c) pv = &d; (d) pc = (char*)pv;
Упражнение 4.38. Объясните следующее выражение:
double slope = static_cast
4.12. Таблица приоритетов операторов
Таблица 4.4. Приоритет операторов
Порядок и оператор | Действие | Применение | Раздел | |
Л :: | Глобальная область видимости | :: имя | 7.4.1 | |
Л :: | Область видимости класса | класс :: имя | 3.2.2 | |
Л :: | Область видимости пространства имен | пространствоимен :: имя | 3.1 | |
Л . | Обращение к члену класса | объект . член | 1.5.2 | |
Л -> | Обращение к члену класса | pointer -> член | 3.4.1 | |
Л [] | Индексирование | выражение [ выражение ] | 3.5.2 | |
Л () | Вызов функции | имя ( список_выражений ) | 1.5.2 | |
Л () | Конструкция type | тип ( список_выражений ) | 4.11.3 | |
П ++ | Постфиксный инкремент | l-значение ++ | 4.5 | |
П -- | Постфиксный декремент | l-значение -- | 4.5 | |
П typeid | Идентификатор типа | typeid( тип ) | 19.2.2 | |
П typeid | Идентификатор типа времени выполнения | typeid( выражение ) | 19.2.2 | |
П Явное приведение | Преобразование типов | cast_имя< тип >( выражение ) | 4.11.3 | |
П ++ | Префиксный инкремент | ++ l-значение | 4.5 | |
П -- | Префиксный декремент | -- l-значение | 4.5 | |
П ~ | Побитовое NOT | ~ выражение | 4.8 | |
П ! | Логическое NOT | ! выражение | 4.3 | |
П - | Унарный минус | - выражение | 4.2 | |
П + | Унарный плюс | + выражение | 4.2 | |
П * | Обращение к значению | * выражение | 2.3.2 | |
П & | Обращение к адресу | & l-значение | 2.3.2 | |
П () | Преобразование типов | ( тип ) выражение | 4.11.3 | |
П sizeof | Размер объекта | sizeof выражение | 4.9 | |
П sizeof | Размер типа | sizeof( тип ) | 4.9 | |
П sizeof... | Размер пакета параметров | sizeof...( имя ) | 16.4 | |
П new | Создание объекта | new тип | 12.1.2 | |
П new[] | Создание массива | new тип [ размер ] | 12.1.2 | |
П delete | Освобождение объекта | delete выражение | 12.1.2 | |
П delete[] | Освобождение массива | delete[] выражение | 12.1.2 | |
П noexcept | Способность к передаче | noexcept( выражение ) | 18.1.4 | |
Л ->* | Указатель на член класса | указатель ->* указатель_на_член | 19.4.1 | |
Л .* | Указатель на член класса | объект .* указатель_на_член | 19.4.1 | |
Л * | Умножение | выражение * выражение | 4.2 | |
Л / | Деление | выражение / выражение | 4.2 | |
Л % | Деление по модулю (остаток) | выражение % выражение | 4.2 | |
Л + | Сумма | выражение + выражение | 4.2 | |
Л - | Разница | выражение - выражение | 4.2 | |
Л << | Побитовый сдвиг влево | выражение << выражение | 4.8 | |
Л >> | Побитовый сдвиг вправо | выражение >> выражение | 4.8 | |
Л < | Меньше | выражение < выражение | 4.3 | |
Л <= | Меньше или равно | выражение <= выражение | 4.3 | |
Л > | Больше | выражение > выражение | 4.3 | |
Л >= | Больше или равно | выражение >= выражение | 4.3 | |
Л == | Равенство | выражение == выражение | 4.3 | |
Л != | Неравенство | выражение != выражение | 4.3 | |
Л & | Побитовый AND | выражение & выражение | 4.8 | |
Л ^ | Побитовый XOR | выражение ^ выражение | 4.8 | |
Л | | Побитовый OR | выражение | выражение | 4.8 | |
Л && | Логический AND | выражение && выражение | 4.3 | |
Л || | Логический OR | выражение || выражение | 4.3 | |
П ?: | Условный оператор | выражение ? выражение : выражение | 4.7 | |
П = | Присвоение | l-значение = выражение | 4.4 | |
П *= , /= , %= , | Составные операторы присвоения | l-значение += выражение , и т.д. | 4.4 | |
П += , -= , | 4.4 | |||
П <<= , >>= , | 4.4 | |||
П &= , |= , ^= | 4.4 | |||
П throw | Передача исключения | throw выражение | 4.6.1 | |
Л , | Запятая | выражение, выражение | 4.10 |
Резюме
Язык С++ предоставляет богатый набор операторов и определяет их назначение, когда они относятся к значениям встроенных типов. Кроме того, язык поддерживает перегрузку операторов, позволяющую самостоятельно определять назначение операторов для типов класса. Определение операторов для собственных типов рассматривается в главе 14.
Чтобы разобраться в составных выражениях (содержащих несколько операторов), необходимо выяснить приоритет и порядок обработки операндов. Каждый оператор имеет приоритет и порядок. Приоритет определяет группировку операторов в составном выражении, а порядок определяет группировку операторов с одинаковым уровнем приоритета.
Для большинства операторов порядок выполнения операндов не определен, компилятор выбирает сам, какой операнд обработать сначала — левый или правый. Зачастую порядок вычисления результатов операндов никак не влияет на результат выражения. Но если оба операнда обращаются к одному объекту, причем один из них изменяет объект, то порядок выполнения становится весьма важен, а связанные с ним серьезные ошибки обнаружить крайне сложно.
И наконец, компилятор зачастую сам преобразует тип операндов в другой связанный тип. Например, малые целочисленные типы преобразуются в больший целочисленный тип каждого выражения. Преобразования существуют и для встроенных типов, и для классов. Преобразования могут быть также осуществлены явно, при помощи приведения.
Термины
L-значение (l-value). Выражение, возвращающее объект или функцию. Неконстантное l-значение обозначает объект, который может быть левым операндом оператора присвоения.
R-значение (r-value). Выражение, возвращающее значение, но не ассоциированную с ним область, если таковое значение вообще имеется.
Арифметическое преобразование (arithmetic conversion). Преобразование одного арифметического типа в другой. В контексте парных арифметических операторов арифметические преобразования, как правило, сохраняют точность, преобразуя значения меньшего типа в значения большего (например, меньший целочисленный тип char или short преобразуется в int).
Выражение (expression). Самый низкий уровень вычислений в программе на языке С++. Как правило, выражения состоят из одного или нескольких операторов. Каждое выражение возвращает результат. Выражения могут использоваться в качестве операндов, что позволяет создавать составные выражения, которым для вычисления собственного результата нужны результаты других выражений, являющихся его операндами.
Вычисление по сокращенной схеме (short-circuit evaluation). Термин, описывающий способ выполнения операторов логического AND и OR. Если первого операнда этих операторов достаточно для определения общего результата, то остальные операнды не рассматриваются и не вычисляются.
Неявное преобразование (implicit conversion). Преобразование, которое осуществляется компилятором автоматически. Такое преобразование осуществляется в случае, когда оператор получает значение, тип которого отличается от необходимого. Компилятор автоматически преобразует операнд в необходимый тип, если соответствующее преобразование определено.
Операнд (operand). Значение, с которым работает выражение. У каждого оператора есть один или несколько операндов
Оператор --. Оператор декремента. Имеет две формы, префиксную и постфиксную. Префиксный оператор декремента возвращает l-значение. Он вычитает единицу из значения операнда и возвращает полученное значение. Постфиксный оператор декремента возвращает r-значение. Он вычитает единицу из значения операнда, но возвращает исходное, неизмененное значение. Примечание: итераторы имеют оператор -- даже если у них нет оператора -.
Оператор !. Оператор логического NOT. Возвращает инверсное значение своего операнда типа bool. Результат true, если операнд false, и наоборот.
Оператор &. Побитовый оператор AND. Создает новое целочисленное значение, в котором каждая битовая позиция имеет значение 1, если оба операнда в этой позиции имеют значение 1. В противном случае бит получает значение 0.
Оператор &&. Оператор логического AND. Возвращает значение true, если оба операнда истинны. Правый операнд обрабатывается, только если левый операнд истинен.
Оператор ,. Оператор запятая. Бинарный оператор, обрабатывающийся слева направо. Результатом оператора запятая является значение справа. Результат является l-значением, только если его операнд — l-значение.
Оператор ?:. Условный оператор. Сокращенная форма конструкции if...else следующего вида: условие ? выражение1 : выражение2 . Если условие истинно (значение true) выполняется выражение1 , в противном случае — выражение2 . Тип выражений должен совпадать или допускать преобразование в общий тип. Выполняется только одно из выражений.
Оператор ^. Побитовый оператор XOR. Создает новое целочисленное значение, в котором каждая битовая позиция имеет значение 1, если любой (но не оба) из операндов содержит значение 1 в этой битовой позиции. В противном случае бит получает значение 0.
Оператор |. Побитовый оператор OR. Создает новое целочисленное значение, в котором каждая битовая позиция имеет значение 1, если любой из операндов содержит значение 1 в этой битовой позиции. В противном случае бит получает значение 0.
Оператор ||. Оператор логического OR. Возвращает значение true, если любой из операндов истинен. Правый операнд обрабатывается, только если левый операнд ложен.
Оператор ~. Побитовый оператор NOT. Инвертирует биты своего операнда.
Оператор ++. Оператор инкремента. Оператор инкремента имеет две формы, префиксную и постфиксную. Префиксный оператор инкремента возвращает l-значение. Он добавляет единицу к значению операнда и возвращает полученное значение. Постфиксный оператор инкремента возвращает r-значение. Он добавляет единицу к значению операнда, но возвращает исходное, неизмененное значение. Примечание: итераторы имеют оператор ++, даже если у них нет оператора +.
Оператор <<. Оператор сдвига влево. Сдвигает биты левого операнда влево. Количество позиций, на которое осуществляется сдвиг, задает правый операнд. Правый операнд должен быть нулем или положительным значением, ни в коем случае не превосходящим количества битов в левом операнде. Левый операнд должен быть беззнаковым; если левый операнд будет иметь знаковый тип, то сдвиг бита знака приведет к непредсказуемому результату.
Оператор >>. Оператор сдвига вправо. Аналогичен оператору сдвига влево, за исключением направления перемещения битов. Правый операнд должен быть нулем или положительным значением, ни в коем случае не превосходящим количества битов в левом операнде. Левый операнд должен быть беззнаковым; если левый операнд будет иметь знаковый тип, то сдвиг бита знака приведет к непредсказуемому результату.
Оператор const_cast. Применяется при преобразовании объекта со спецификатором const нижнего уровня в соответствующий неконстантный тип, и наоборот.
Оператор dynamic_cast. Используется в комбинации с наследованием и идентификацией типов во время выполнения. См. раздел 19.2.
Оператор reinterpret_cast. Интерпретирует содержимое операнда как другой тип. Очень опасен и жестко зависит от машины.
Оператор sizeof. Возвращает размер в байтах объекта, указанного по имени типа, или типа переданного выражения.
Оператор static_cast. Запрос на явное преобразование типов, которое компилятор осуществил бы неявно. Зачастую используется для переопределения неявного преобразования, которое в противном случае выполнил бы компилятор.
Оператор (operator). Символ, который определяет действие, выполняемое выражением. В языке определен целый набор операторов, которые применяются для значений встроенных типов. В языке определен также приоритет и порядок выполнения для каждого оператора, а также задано количество операндов для каждого из них. Операторы могут быть перегружены и применены к объектам классов.
Парный оператор (binary operator). Операторы, в которых используются два операнда.
Перегруженный оператор (overloaded operator). Версия оператора, определенного для использования с объектом класса. Определение перегруженных версий операторов рассматривается в главе 14.
Порядок (associativity). Определяет последовательность выполнения операторов одинакового приоритета. Операторы могут иметь правосторонний (справа налево) или левосторонний (слева направо) порядок выполнения.
Порядок вычисления (order of evaluation). Порядок, если он есть, определяет последовательность вычисления операндов оператора. В большинстве случаев компилятор С++ самостоятельно определяет порядок вычисления операндов. Однако, прежде чем выполнится сам оператор, всегда вычисляются его операнды. Только операторы &&, ||, ?: и , определяют порядок выполнения своих операндов.
Преобразование (conversion). Процесс, в ходе которого значение одного типа преобразуется в значение другого типа. Преобразования между встроенными типами заложены в самом языке. Для классов также возможны преобразования типов.
Преобразование (promotion). См. целочисленное преобразование.
Приведение (cast). Явное преобразование типов.
Приоритет (precedence). Определяет порядок выполнения операторов в выражении. Операторы с более высоким приоритетом выполняются прежде операторов с более низким приоритетом.
Результат (result). Значение или объект, полученный при вычислении выражения.
Составное выражение (compound expression). Выражение, состоящее из нескольких операторов.
Унарный оператор (unary operator). Оператор, использующий один операнд.
Целочисленное преобразование (integral promotion). Подмножество стандартных преобразований, при которых меньший целочисленный тип приводится к ближайшему большему типу. Операнды меньших целочисленных типов (например, short, char и т.д.) преобразуются всегда, даже если такие преобразования, казалось бы, необязательны.
Глава 5
Операторы
Подобно большинству языков, язык С++ предоставляет операторы для условного выполнения кода, циклы, позволяющие многократно выполнять те же фрагменты кода, и операторы перехода, прерывающие поток выполнения. В данной главе операторы, поддерживаемые языком С++, рассматриваются более подробно.
Операторы (statement) выполняются последовательно. За исключением самых простых программ последовательного выполнения недостаточно. Поэтому язык С++ определяет также набор операторов управления потоком (flow of control), обеспечивающих более сложные пути выполнения кода.
5.1. Простые операторы
Большинство операторов в языке С++ заканчиваются точкой с запятой. Выражение типа ival + 5 становится оператором выражения (expression statement), завершающимся точкой с запятой. Операторы выражения составляют вычисляемую часть выражения.
ival + 5; // оператор выражения (хоть и бесполезный)
cout << ival; // оператор выражения
Первое выражение бесполезно: результат вычисляется, но не присваивается, а следовательно, никак не используется. Как правило, выражения содержат операторы, результат вычисления которых влияет на состояние программы. К таким операторам относятся присвоение, инкремент, ввод и вывод.
Пустые операторы
Самая простая форма оператора — это пустой (empty), или нулевой, оператор (null statement). Он представляет собой одиночный символ точки с запятой (;).
; // пустой оператор
Пустой оператор используется в случае, когда синтаксис языка требует наличия оператора, а логика программы — нет. Как правило, это происходит в случае, когда вся работа цикла осуществляется в его условии. Например, можно организовать ввод, игнорируя все прочитанные данные, пока не встретится определенное значение:
// читать, пока не встретится конец файла или значение,
// равное содержимому переменной sought
while (cin >> s && s != sought)
; // пустой оператор
В условии значение считывается со стандартного устройства ввода, и объект cin неявно проверяется на успешность чтения. Если чтение прошло успешно, во второй части условия проверяется, не равно ли полученное значение содержимому переменной sought. Если искомое значение найдено, цикл while завершается, в противном случае его условие проверяется снова, начиная с чтения следующего значения из объекта cin.
Случаи применения пустого оператора следует комментировать, чтобы любой, кто читает код, мог сразу понять, что оператор пропущен преднамеренно.
Остерегайтесь пропущенных и лишних точек с запятой
Поскольку пустой оператор является вполне допустимым, он может располагаться везде, где ожидается оператор. Поэтому лишний символ точки с запятой, который может показаться явно недопустимым, на самом деле является не более, чем пустым оператором. Приведенный ниже фрагмент кода содержит два оператора: оператор выражения и пустой оператор.
ival = v1 + v2;; // ok: вторая точка с запятой - это лишний
// пустой оператор
Хотя ненужный пустой оператор зачастую безопасен, дополнительная точка с запятой после условия цикла while или оператора if может решительно изменить поведение кода. Например, следующий цикл будет выполняться бесконечно:
// катастрофа: лишняя точка с запятой превратила тело цикла
// в пустой оператор
while (iter != svec.end()) ; // тело цикла while пусто!
++iter; // инкремент не является частью цикла
Несмотря на отступ, выражение с оператором инкремента не является частью цикла. Тело цикла — это пустой оператор, обозначенный символом точки с запятой непосредственно после условия.
Лишний пустой оператор не всегда безопасен.
Составные операторы (блоки)
Составной оператор (compound statement), обычно называемый блоком (block), представляет собой последовательность операторов, заключенных в фигурные скобки. Блок операторов обладает собственной областью видимости (см. раздел 2.2.4). Объявленные в блоке имена доступны только в данном блоке и блоках, вложенных в него. Как обычно, имя видимо только с того момента, когда оно определено, и до конца блока включительно.
Составные операторы применяются в случае, когда язык требует одного оператора, а логика программы нескольких. Например, тело цикла while или for составляет один оператор. Но в теле цикла зачастую необходимо выполнить несколько операторов. Заключив необходимые операторы в фигурные скобки, можно получить блок, рассматриваемый как единый оператор.
Для примера вернемся к циклу while из кода в разделе 1.4.1.
while (val <= 10) {
sum += val; // присвоить sum сумму val и sum
++val; // добавить 1 к val
}
Логика программы нуждалась в двух операторах, но цикл while способен содержать только один оператор. Заключив эти операторы в фигурные скобки, получаем один (составной) оператор.
Блок не завершают точкой с запятой.
Как и в случае с пустым оператором, вполне можно создать пустой блок. Для этого используется пара фигурных скобок без операторов:
while (cin >> s && s != sought)
{ } // пустой блок
Упражнения раздела 5.1
Упражнение 5.1. Что такое пустой оператор? Когда его можно использовать?
Упражнение 5.2. Что такое блок? Когда его можно использовать?
Упражнение 5.3. Используя оператор запятой (см. раздел 4.10), перепишите цикл while из раздела 1.4.1 так, чтобы блок стал больше не нужен. Объясните, улучшило ли это удобочитаемость кода.
5.2. Операторная область видимости
Переменные можно определять в управляющих структурах операторов if, switch, while и for. Переменные, определенные в управляющей структуре, видимы только в пределах этого оператора и выходят из области видимости по его завершении.
while (int i = get_num()) // i создается и инициализируется при
// каждой итерации
cout << i << endl;
i = 0; // ошибка: переменная i недоступна вне цикла
Если к значению управляющей переменной необходимо обращаться впоследствии, то ее следует определить вне оператора.
// найти первый отрицательный элемент
auto beg = v.begin();
while (beg != v.end() && *beg >= 0)
++beg;
if (beg == v.end())
// известно, что все элементы v больше или равны нулю
Значение объекта, определенного в управляющей структуре, используется самой структурой. Поэтому такие переменные следует инициализировать.
Упражнения раздела 5.2
Упражнение 5.4. Объясните каждый из следующих примеров, а также устраните все обнаруженные проблемы.
(a) while (string::iterator iter != s.end()) { /* ... */ }
(b) while (bool status = find(word)) { /* ... */ }
if (!status) { /* ... */ }
5.3. Условные операторы
Язык С++ предоставляет два оператора, обеспечивающих условное выполнение. Оператор if разделяет поток выполнения на основании условия. Оператор switch вычисляет результат целочисленного выражения и на его основании выбирает один из нескольких путей выполнения.
5.3.1. Оператор
if
Операторif выполняет один из двух операторов в зависимости от истинности своего условия. Существуют две формы оператора if: с разделом else и без него. Синтаксис простой формы оператора if имеет следующий вид:
if ( условие )
оператор
Оператор if else имеет следующую форму:
if ( условие )
оператор
else
оператор2
В обеих версиях условие заключается в круглые скобки. Условие может быть выражением или инициализирующим объявлением переменной (см. раздел 5.2). Тип выражения или переменной должен быть преобразуем в тип bool (см. раздел 4.11). Как обычно, и оператор , и оператор2 могут быть блоком.
Если условие истинно, оператор выполняется. По завершении оператора выполнение продолжается после оператора if.
Если условие ложно, оператор пропускается. В простом операторе if выполнение продолжается после оператора if, а в операторе if else выполняется оператор2 .
Использование оператора if else
Для иллюстрации оператора if else вычислим символ оценки по ее числу. Подразумевается, что числовые значения оценок находятся в диапазоне от нуля до 100 включительно. Оценка 100 получает знак "А++", оценка ниже 60 — "F", а остальные группируются по десять: от 60 до 69 — "D", от 70 до 79 — "C" и т.д. Для хранения возможных символов оценок используем вектор:
vector
Для решения этой проблемы можно использовать оператор if else, чтобы выполнять разные действия проходных и не проходных отметок.
// если оценка меньше 60 - это F, в противном случае вычислять индекс
string lettergrade;
if (grade < 60)
lettergrade = scores[0];
else
lettergrade = scores[(grade - 50)/10];
В зависимости от значения переменной grade оператор выполняется либо после части if, либо после части else. В части else вычисляется индекс оценки уже без неудовлетворительных. Затем усекающее остаток целочисленное деление (см. раздел 4.2) используется для вычисления соответствующего индекса вектора scores.
Вложенные операторы if
Чтобы сделать программу интересней, добавим к удовлетворительным отметкам плюс или минус. Плюс присваивается оценкам, заканчивающимся на 8 или 9, а минус — заканчивающимся на 0, 1 или 2.
if (grade % 10 > 7)
lettergrade += '+'; // оценки, заканчивающиеся на 8 или 9, получают +
else if (grade % 10 < 3)
lettergrade += '-'; // оценки, заканчивающиеся на 0, 1 и 2, получают -
Для получения остатка и принятия на основании его решения, добавлять ли плюс или минус, используем оператор деления по модулю (см. раздел 4.2).
Теперь добавим код, присваивающий плюс или минус, к коду, выбирающему символ оценки:
// если оценка неудовлетворительна, нет смысла проверять ее на + или -
if (grade < 60)
lettergrade = scores[0];
else {
lettergrade = scores[(grade - 50)/10]; // выбрать символ оценки
if (grade != 100) // добавлять + или -, только если это не А++
if (grade % 10 > 7)
lettergrade += '+'; // оценки, заканчивающиеся на 8 или 9,
// получают +
else if (grade % 10 < 3)
lettergrade += '-'; // оценки, заканчивающиеся на 0, 1 и 2,
// получают -
}
Обратите внимание, что два оператора, следующих за первым оператором else, заключены в блок. Если переменная grade содержит значение 60 или больше, возможны два действия: выбор символа оценки из вектора scores и, при условии, добавление плюса или минуса.
Следите за фигурными скобками
Когда несколько операторов следует выполнить как блок, довольно часто забывают фигурные скобки. В следующем примере, вопреки отступу, код добавления плюса или минуса выполняется безусловно:
if (grade < 60)
lettergrade = scores[0];
else // ошибка: отсутствует фигурная скобка
lettergrade = scores[(grade - 50)/10];
// несмотря на внешний вид, без фигурной скобки, этот код
// выполняется всегда
// неудовлетворительным оценкам ошибочно присваивается - или +
if (grade != 100)
if (grade % 10 > 7)
lettergrade += '+'; // оценки, заканчивающиеся на 8 или 9,
// получают +
else if (grade % 10 < 3)
lettergrade += '-'; // оценки, заканчивающиеся на 0, 1 и 2,
// получают -
Найти такую ошибку бывает очень трудно, поскольку программа выглядит правильно.
Во избежание подобных проблем некоторые стили программирования рекомендуют всегда использовать фигурные скобки после оператора if или else (а также вокруг тел циклов while и for).
Это позволяет избежать подобных ошибок. Это также означает, что фигурные скобки уже есть, если последующие изменения кода потребуют добавления операторов.
У большинства редакторов и сред разработки есть инструменты автоматического выравнивания исходного кода в соответствии с его структурой. Такие инструменты всегда следует использовать, если они доступны.
Потерянный оператор else
Когда один оператор if вкладывается в другой, ветвей if может оказаться больше, чем ветвей else. Действительно, в нашей программе оценивания четыре оператора if и два оператора else. Возникает вопрос: как установить, которому оператору if принадлежит данный оператор else?
Эта проблема, обычно называемая потерянным оператором else (dangling else), присуща многим языкам программирования, предоставляющим операторы if и if else. Разные языки решают эту проблему по-разному. В языке С++ неоднозначность решается так: оператор else принадлежит ближайшему расположенному выше оператору if без else.
Неприятности происходят также, когда код содержит больше операторов if, чем ветвей else. Для иллюстрации проблемы перепишем внутренний оператор if else, добавляющий плюс или минус, на основании различных наборов условий:
// Ошибка: порядок выполнения НЕ СООТВЕТСТВУЕТ отступам; ветвь else
// принадлежит внутреннему if
if (grade % 10 >= 3)
if (grade % 10 > 7)
lettergrade += '+'; // оценки, заканчивающиеся на 8 или 9,
// получают +
else
lettergrade += '-'; // оценки, заканчивающиеся на 3, 4, 5, 6,
// получают - !
Отступ в данном коде подразумевает, что оператор else предназначен для внешнего оператора if, т.е. он выполняется, когда значение grade заканчивается цифрой меньше 3. Однако, несмотря на наши намерения и вопреки отступу, ветвь else является частью внутреннего оператора if. Этот код добавляет '-' к оценкам, заканчивающимся на 3-7 включительно! Правильно выровненный, в соответствии с правилами выполнения, этот код выглядел бы так:
// отступ соответствует порядку выполнения,
// но не намерению программиста
if (grade % 10 >= 3)
if (grade % 10 > 7)
lettergrade += '+'; // оценки, заканчивающиеся на 8 или 9,
// получают +
else
lettergrade += '-'; // оценки, заканчивающиеся на 3, 4, 5, 6,
// получают - !
Контроль пути выполнения при помощи фигурных скобок
Заключив внутренний оператор if в блок, можно сделать ветвь else частью внешнего оператора if:
// добавлять плюс для оценок, заканчивающихся на 8 или 9, а минус для
// заканчивающихся на 0, 1 или 2
if (grade % 10 >= 3) {
if (grade % 10 > 7)
lettergrade += '+'; // оценки, заканчивающиеся на 8 или 9,
// получают +
} else // скобки обеспечивают else для внешнего if
lettergrade += '-'; // оценки, заканчивающиеся на 0, 1 и 2,
// получают -
Операторы не распространяются за границы блока, поэтому внутренний цикл if заканчивается на закрывающей фигурной скобке перед оператором else. Оператор else не может быть частью внутреннего оператора if. Теперь ближайшим свободным оператором if оказывается внешний, как и предполагалось изначально.
Упражнения раздела 5.3.1
Упражнение 5.5. Напишите собственную версию программы преобразования числовой оценки в символ с использованием оператора if else.
Упражнение 5.6. Перепишите программу оценки так, чтобы использовать условный оператор (см. раздел 4.7) вместо оператора if else.
Упражнение 5.7. Исправьте ошибки в каждом из следующих фрагментов кода:
(a) if (ival1 != ival2)
ival1 = ival2
else ival1 = ival2 = 0;
(b) if (ival < minval)
minval = ival;
occurs = 1;
(c) if (int ival = get_value())
cout << "ival = " << ival << endl;
if (!ival)
cout << "ival = 0\n";
(d) if (ival = 0)
ival = get_value();
Упражнение 5.8. Что такое "потерянный оператор else"? Как в языке С++ определяется принадлежность ветви else?
5.3.2. Оператор
switch
Операторswitch предоставляет более удобный способ выбора одной из множества альтернатив. Предположим, например, что необходимо рассчитать, как часто встречается каждая из пяти гласных в некотором фрагменте текста. Программа будет иметь следующую логику.
• Читать каждый введенный символ.
• Сравнить каждый символ с набором искомых гласных.
• Если символ соответствует одной из гласных букв, добавить 1 к соответствующему счетчику.
• Отобразить результаты.
Программа должна отобразить результаты в следующем виде:
Number of vowel а: 3195
Number of vowel e: 6230
Number of vowel i: 3102
Number of vowel o: 3289
Number of vowel u: 1033
Для непосредственного решения этой задачи можно использовать оператор switch.
// инициализировать счетчики для каждой гласной
unsigned aCnt = 0, eCnt = 0, iCnt = 0, oCnt = 0, uCnt = 0;
char ch;
while (cin >> ch) {
// если ch - гласная, увеличить соответствующий счетчик
switch (ch) {
case 'a':
++aCnt;
break;
case 'e':
++eCnt;
break;
case 'i':
++iCnt;
break;
case 'o':
++oCnt;
break;
case 'u':
++uCnt;
break;
}
}
// вывод результата
cout << "Number of vowel a: \t" << aCnt << '\n'
<< "Number of vowel e: \t" << eCnt << '\n'
<< "Number of vowel i: \t" << iCnt << '\n'
<< "Number of vowel o: \t" << oCnt << '\n'
<< "Number of vowel u: \t" << uCnt << endl;
Оператор switch вычисляет результат выражения, расположенного за ключевым словом switch. Это выражение может быть объявлением инициализированной переменной (см. раздел 5.2). Выражение преобразуется в целочисленный тип. Результат выражения сравнивается со значением, ассоциированным с каждым оператором case.
Если результат выражения соответствует значению метки case, выполнение кода начинается с первого оператора после этой метки. В принципе выполнение кода продолжается до конца оператора switch, но оно может быть прервано оператором break.
Более подробно оператор break рассматривается в разделе 5.5.1, а пока достаточно знать, что он прерывает текущий поток выполнения. В данном случае оператор break передает управление первому оператору после оператора switch. Здесь оператор switch является единственным оператором в теле цикла while, поэтому его прерывание возвращает контроль окружающему оператору while. Поскольку в нем нет никаких других операторов, цикл while продолжается, если его условие выполняется.
Если соответствия не найдено, выполнение сразу переходит к первому оператору после switch. Как уже упоминалось, в этом примере выход из оператора switch передает управление условию цикла while.
Ключевое слово case и связанное с ним значение называют также меткой case (case label). Значением каждой метки case является константное выражение (см. раздел 2.4.4).
char ch = getVal();
int ival = 42;
switch(ch) {
case 3.14: // ошибка: метка case не целое число
case ival: // ошибка: метка case не константа
// ...
Одинаковые значения меток case недопустимы. Существует также специальная метка default, рассматриваемая ниже.
Порядок выполнения в операторе switch
Важно понимать, как управление передается между метками case. После обнаружения соответствующей метки case выполнение начинается с нее и продолжается далее через все остальные метки до конца или пока выполнение не будет прервано явно. Во избежание выполнения последующих разделов case выполнение следует прервать явно, поэтому оператор break обычно является последним оператором перед следующей меткой case.
Однако возможны ситуации, когда необходимо именно стандартное поведение оператора switch. У каждой метки case может быть только одно значение, однако две или более метки могут совместно использовать единый набор действий. В таких ситуациях достаточно пропустить оператор break и позволить программе пройти несколько меток case.
Например, можно было бы посчитать общее количество гласных так:
unsigned vowelCnt = 0;
// ...
switch (ch) {
// для инкремента vowelCnt подойдет любая буква а, е, i, о или u
case 'a':
case 'e':
case 'i':
case 'o':
case 'u':
++vowelCnt;
break;
}
Здесь расположено несколько меток case подряд без оператора break. Теперь при любой гласной в переменной ch будет выполняться тот же код.
Поскольку язык С++ не требует обязательно располагать метки case в отдельной строке, весь диапазон значений можно указать в одной строке:
switch (ch) {
// альтернативный допустимый синтаксис
case 'a': case 'e': case 'i': case 'o': case 'u':
++vowelCnt;
break;
}
Случаи, когда оператор break пропускают преднамеренно, довольно редки, поэтому их следует обязательно комментировать, объясняя логику действий.
Пропуск оператора break — весьма распространенная ошибка
Весьма распространено заблуждение, что выполняются только те операторы, которые связаны с совпавшей меткой case. Вот пример неправильной реализации подсчета гласных в операторе switch:
// внимание: преднамеренно неправильный код!
switch (ch) {
case 'a' :
++aCnt; // Упс! Необходим оператор break
case 'e':
++eCnt; // Упс! Необходим оператор break
case 'i':
++iCnt; // Упс! Необходим оператор break
case 'o':
++oCnt; // Упс! Необходим оператор break
case 'u':
++uCnt;
}
Чтобы понять происходящее, предположим, что значением переменной ch является 'e'. Выполнение переходит к коду после метки case 'e', где происходит инкремент переменной eCnt. Выполнение продолжается далее через метки case, увеличивая также значения переменных iCnt, oCnt и uCnt.
Несмотря на то что оператор break и не обязателен после последней метки оператора switch, использовать его все же рекомендуется. Ведь если впоследствии оператор switch будет дополнен еще одной меткой case, отсутствие оператора break после прежней последней метки не создаст проблем.
Метка default
Операторы после метки default выполняются, если ни одна из меток case не соответствует значению выражения оператора switch. Например, в рассматриваемый код можно добавить счетчик негласных букв. Значение этого счетчика по имени otherCnt будет увеличиваться в случае default:
// если ch гласная, увеличить соответствующий счетчик
switch (ch) {
case 'a': case 'e': case 'i': case 'o': case 'u':
++vowelCnt;
break;
default:
++otherCnt;
break;
}
В этой версии, если переменная ch не содержит гласную букву, управление перейдет к метке default и увеличится значение счетчика otherCnt.
Раздел default имеет смысл создавать всегда, даже если в нем не происходит никаких действий. Впоследствии это однозначно укажет читателю кода, что случай default не был забыт, т.е. для остальных случаев никаких действий предпринимать не нужно.
Метка не может быть автономной; она должна предшествовать оператору или другой метке case. Если оператор switch заканчивается разделом default, в котором не осуществляется никаких действий, за меткой default должен следовать пустой оператор или пустой блок.
Определение переменной в операторе switch
Как уже упоминалось, выполнение оператора switch способно переходить через метки case. Когда выполнение переходит к некой метке case, весь расположенный выше код оператора switch будет проигнорирован. Факт игнорирования кода поднимает интересный вопрос: что будет, если пропущенный код содержит определение переменной?
Ответ прост: недопустим переход с места, где переменная с инициализатором уже вышла из области видимости к месту, где эта переменная находится в области видимости.
case true:
// этот оператор switch недопустим, поскольку инициализацию
// можно обойти
string file_name; // ошибка: выполнение обходит неявно
// инициализированную переменную
int ival = 0; // ошибка: выполнение обходит неявно
// инициализированную переменную
int jval; // ok: поскольку jval не инициализирована
break;
case false:
// ok: jval находится в области видимости, но она не инициализирована
jval = next_num(); // ok: присвоить значение jval
if (file_name.empty()) // file_name находится в области видимости, но
// она не инициализирована
// ...
Если бы этот код был допустим, то любой переход к случаю false обходил бы инициализацию переменных file_name и ival, но они оставались бы в области видимости и код вполне мог бы использовать их. Однако эти переменные не были бы инициализированы. В результате язык не позволяет перепрыгивать через инициализацию, если инициализированная переменная находится в области видимости в пункте, к которому переходит управление.
Если необходимо определить и инициализировать переменную для некоего случая case, то сделать это следует в блоке, гарантируя таким образом, что переменная выйдет из области видимости перед любой последующей меткой.
case true:
{
// ok : оператор объявления в пределах операторного блока
string file_name = get_file_name();
// ...
}
break;
case false:
if (file_name.empty()) // ошибка: file_name вне области видимости
Упражнения раздела 5.3.2
Упражнение 5.9. Напишите программу, использующую серию операторов if для подсчета количества гласных букв в тексте, прочитанном из потока cin.
Упражнение 5.10. Программа подсчета гласных имеет одну проблему: она не учитывает заглавные буквы как гласные. Напишите программу, которая подсчитывает гласные буквы как в верхнем, так и в нижнем регистре. То есть значение счетчика aCnt должно увеличиваться при встрече как символа 'a', так и символа 'A' (аналогично для остальных гласных букв).
Упражнение 5.11. Измените рассматриваемую программу так, чтобы она подсчитывала также количество пробелов, символов табуляции и новой строки.
Упражнение 5.12. Измените рассматриваемую программу так, чтобы она подсчитывала количество встреченных двухсимвольных последовательностей: ff, fl и fi.
Упражнение 5.13. Каждая из приведенных ниже программ содержит распространенную ошибку. Выявите и исправьте каждую из них.
Код для упражнения 5.13
(a) unsigned aCnt = 0, eCnt = 0, iouCnt = 0;
char ch = next_text();
switch (ch) {
case 'a': aCnt++;
case 'e': eCnt++;
default: iouCnt++;
}
(b) unsigned index = some_value();
switch (index) {
case 1:
int ix = get_value();
ivec[ix] = index;
break;
default:
ix = ivec.size()-1;
ivec[ix] = index;
(c) unsigned evenCnt = 0, oddCnt = 0;
int digit = get_num() % 10;
switch (digit) {
case 1, 3, 5, 7, 9:
oddcnt++;
break;
case 2, 4, 6, 8, 10:
evencnt++;
break;
}
(d) unsigned ival=512, jval=1024, kval=4096;
unsigned bufsize;
unsigned swt = get_bufCnt();
switch(swt) {
case ival:
bufsize = ival * sizeof (int);
break;
case jval:
bufsize = jval * sizeof(int);
break;
case kval:
bufsize = kval * sizeof(int);
break;
}
5.4. Итерационные операторы
Итерационные операторы (iterative statement), называемые также циклами (loop), обеспечивают повторное выполнение кода, пока их условие истинно. Операторы while и for проверяют условие прежде, чем выполнить тело. Оператор do while сначала выполняет тело, а затем проверяет свое условие.
5.4.1. Оператор
while
Операторwhile многократно выполняет оператор, пока его условие остается истинным. Его синтаксическая форма имеет следующий вид:
while ( условие )
оператор
Пока условие истинно (значение true), оператор (который зачастую является блоком кода) выполняется. Условие не может быть пустым. Если при первой проверке условие ложно (значение false), оператор не выполняется.
Условие может быть выражением или объявлением инициализированной переменной (см. раздел 5.2). Обычно либо само условие, либо тело цикла должно делать нечто изменяющее значение выражения. В противном случае цикл никогда не закончится.
Переменные, определенные в условии или теле оператора while, создаются и удаляются при каждой итерации.
Использование цикла while
Цикл while обычно используется в случае, когда итерации необходимо выполнять неопределенное количество раз, например, при чтении ввода. Цикл while полезен также при необходимости доступа к значению управляющей переменной после завершения цикла. Рассмотрим пример.
vector
int i;
// читать до конца файла или отказа ввода
while (cin >> i)
v.push_back(i); // найти первый отрицательный элемент
auto beg = v.begin();
while (beg != v.end() && *beg >= 0)
++beg;
if (beg == v.end())
// известно, что все элементы v больше или равны нулю
Первый цикл читает данные со стандартного устройства ввода. Он может выполняться сколько угодно раз. Условие становится ложно, когда поток cin читает недопустимые данные, происходит ошибка ввода или встречается конец файла. Второй цикл продолжается до тех пор, пока не будет найдено отрицательное значение. Когда цикл заканчивается, переменная beg будет либо равна v.end(), либо обозначит элемент вектора v, значение которого меньше нуля. Значение переменной beg можно использовать вне цикла while для дальнейшей обработки.
Упражнения раздела 5.4.1
Упражнение 5.14. Напишите программу для чтения строк со стандартного устройства ввода и поиска совпадающих слов. Программа должна находить во вводе места, где одно слово непосредственно сопровождается таким же. Отследите наибольшее количество повторений и повторяемое слово. Отобразите максимальное количество дубликатов или сообщение, что никаких повторений не было. Например, при вводе how now now now brown cow cow вывод должен указать, что слово now встретилось три раза.
5.4.2. Традиционный оператор
for
Операторfor имеет следующий синтаксис:
for ( инициализирующий-оператор условие ; выражение )
оператор
Слово for и часть в круглых скобках зачастую упоминают как заголовок for (for header).
Инициализирующий-оператор должен быть оператором объявления, выражением или пустым оператором. Каждый из этих операторов завершается точкой с запятой, поэтому данную синтаксическую форму можно рассматривать так:
for ( инициализатор ; условие ; выражение )
оператор
Как правило, инициализирующий-оператор используется для инициализации или присвоения исходного значения переменной, изменяемой в цикле. Для управления циклом служит условие . Пока условие истинно, оператор выполняется. Если при первой проверке условие оказывается ложным, оператор не выполняется ни разу. Для изменения значения переменной, инициализированной в инициализирующем операторе и проверяемой в условии, используется выражение . Оно выполняется после каждой итерации цикла. Как и в других случаях, оператор может быть одиночным оператором или блоком операторов.
Поток выполнения в традиционном цикле for
Рассмотрим следующий цикл for из раздела 3.2.3:
// обрабатывать символы,
// пока они не исчерпаются или не встретится пробел
for (decltype(s.size()) index = 0;
index != s.size () && !isspace(s[index]); ++index)
s[index] = toupper(s[index]); // преобразовать в верхний регистр
Порядок его выполнения таков.
1. В начале цикла только однажды выполняется инициализирующий-оператор . В данном случае определяется переменная index и инициализируется нулем.
2. Затем обрабатывается условие . Если index не равен s.size() и символ в элементе s[index] не является пробелом, то выполняется тело цикла for. В противном случае цикл заканчивается. Если условие ложно уже на первой итерации, то тело цикла for не выполняется вообще.
3. Если условие истинно, то тело цикла for выполняется. В данном случае оно переводит символ в элементе s[index] в верхний регистр.
4. И наконец, обрабатывается выражение . В данном случае значение переменной index увеличивается 1.
Эти четыре этапа представляют первую итерацию цикла for. Этап 1 выполняется только однажды при входе в цикл. Этапы 2–4 повторяются, пока условие не станет ложно, т.е. пока не встретится символ пробела в элементе s или пока index не превысит s.size().
Не забывайте, что видимость любого объекта, определенного в пределах заголовка for, ограничивается телом цикла for. Таким образом, в данном примере переменная index недоступна после завершения цикла for.
Несколько определений в заголовке for
Подобно любому другому объявлению, инициализирующий-оператор способен определить несколько объектов. Однако только инициализирующий-оператор может быть оператором объявления. Поэтому у всех переменных должен быть тот же базовый тип (см. раздел 2.3). Для примера напишем цикл, дублирующий элементы вектора в конец следующим образом:
// запомнить размер v и остановиться,
// достигнув первоначально последнего элемента
for (decltype(v.size()) i = 0, sz = v.size(); i != sz; ++i)
v.push_back(v[i]);
В этом цикле инициализирующий-оператор определяется индекс i и управляющая переменная цикла sz.
Пропуск частей заголовка for
В заголовке for может отсутствовать любой (или все) элемент: инициализирующий-оператор , условие или выражение .
Когда инициализация не нужна, вместо инициализирующего оператора можно использовать пустой оператор. Например, можно переписать цикл, который искал первое отрицательное число в векторе так, чтобы использовался цикл for:
auto beg = v.begin();
for ( /* ничего */; beg != v.end() && *beg >= 0; ++beg)
; // ничего не делать
Обратите внимание: для указания на отсутствие инициализирующего оператора точка с запятой необходима, точнее, точка с запятой представляет пустой инициализирующий оператор. В этом цикле for тело также пусто, поскольку все его действия осуществляются в условии и выражении. Условие решает, когда придет время прекращать просмотр, а выражение увеличивает итератор.
Отсутствие части условие эквивалентно расположению в условии значения true. Поскольку условие истинно всегда, тело цикла for должно содержать оператор, обеспечивающий выход из цикла. В противном случае цикл будет выполняться бесконечно.
for (int i = 0; /* нет условия */ ; ++i) {
// обработка i; код в цикле должен остановить итерацию!
}
В заголовке for может также отсутствовать выражение . В таких циклах либо условие, либо тело должно делать нечто обеспечивающее итерацию. В качестве примера перепишем цикл while, читающий ввод в вектор целых чисел.
vector
for (int i; cin >> i; /* нет выражения */ )
v.push_back(i);
В этом цикле нет никакой необходимости в выражении, поскольку условие изменяет значение переменной i. Условие проверяет входной поток, поэтому цикл заканчивается, когда прочитан весь ввод или произошла ошибка ввода.
Упражнения раздела 5.4.2
Упражнение 5.15. Объясните каждый из следующих циклов. Исправьте все обнаруженные ошибки.
(a) for (int ix = 0; ix != sz; ++ix) { /* ... */ }
if (ix != sz)
// ...
(b) int ix;
for (ix != sz; ++ix) { /* ... */ }
(c) for (int ix = 0; ix != sz; ++ix, ++sz) { /* ... */ }
Упражнение 5.16. Цикл while особенно хорош, когда необходимо выполнить некое условие; например, когда нужно читать значения до конца файла. Цикл for считают циклом пошагового выполнения: индекс проходит диапазон значений в коллекции. Напишите идиоматическое использование каждого цикла, а затем перепишите каждый случаи использования в другой конструкции цикла. Если бы вы могли использовать только один цикл, то какой бы вы выбрали и почему?
Упражнение 5.17. Предположим, есть два вектора целых чисел. Напишите программу, определяющую, не является ли один вектор префиксом другого. Для векторов неравной длины сравнивайте количество элементов меньшего вектора. Например, если векторы содержат значения 0, 1, 1, 2 и 0, 1, 1, 2, 3, 5, 8 соответственно, ваша программа должна возвратить true.
5.4.3. Серийный оператор
for
Новый стандарт ввел упрощенный оператор for, который перебирает элементы контейнера или другой последовательности. Синтаксис серийного оператора for (range for) таков:
for ( объявление : выражение )
оператор
выражение должно представить некую последовательность, такую, как список инициализации (см. раздел 3.3.1), массив (см. раздел 3.5), или объект такого типа, как vector или string, у которого есть функции-члены begin() и end(), возвращающие итераторы (см. раздел 3.4).
объявление определяет переменную. Каждый элемент последовательности должен допускать преобразование в тип переменной (см. раздел 4.11). Проще всего гарантировать соответствие типов за счет использования спецификатора типа auto (см. раздел 2.5.2). Так компилятор выведет тип сам. Если необходима запись в элементы последовательности, то переменная цикла должна иметь ссылочный тип.
На каждой итерации управляющая переменная определяется и инициализируется следующим значением последовательности, а затем выполняется оператор . Как обычно, оператор может быть одиночным оператором или блоком. Выполнение завершается, когда все элементы обработаны.
Несколько таких циклов уже было представлено, но для завершенности рассмотрим цикл, удваивающий значение каждого элемента в векторе:
vector
// для записи в элементы переменная диапазона должна быть ссылкой
for (auto &r : v) // для каждого элемента вектора v
r *= 2; // удвоить значение каждого элемента вектора v
Заголовок for объявляет, что управляющая переменная цикла r связана с вектором v. Чтобы позволить компилятору самостоятельно вывести тип переменной r, используем спецификатор auto. Поскольку предполагается изменение значений элементов вектора v, объявим переменную r как ссылку. При присвоении ей значений в цикле фактически присваивается значение элементу, с которым связана переменная r в данный момент.
Вот эквивалентное определение серийного оператора for в терминах традиционного цикла for:
for (auto beg = v.begin(), end = v.end(); beg != end; ++beg) {
auto &r = *beg; // для изменения элементов r должна быть ссылкой
r *= 2; // удвоить значение каждого элемента вектора v
}
Теперь, когда известно, как работает серийный оператор for, можно понять, почему в разделе 3.3.2 упоминалось о невозможности его использования для добавления элементов к вектору или другому контейнеру. В серийном операторе for кешируется значение end(). Если добавить или удалить элементы из последовательности, сохраненное значение end() станет неверным (см. раздел 3.4.1). Более подробная информация по этой теме приведена в разделе 9.3.6.
5.4.4. Оператор
do while
Операторdo while похож на оператор while, но его условие проверяется после выполнения тела. Независимо от значения условия тело цикла выполняется по крайней мере однажды. Его синтаксическая форма приведена ниже.
do
оператор
while (условие);
После заключенного в скобки условия оператор do while заканчивается точкой с запятой.
В цикле do while оператор выполняется прежде, чем условие . Причем условие не может быть пустым. Если условие ложно, цикл завершается, в противном случае цикл повторяется. Используемые в условии переменные следует определить вне тела оператора do while.
Напишем программу, использующую цикл do while для суммирования любого количества чисел.
// многократно запрашивать у пользователя пары чисел для суммирования
string rsp; // используется в условии, поэтому не может быть
// определена в цикле do
do {
cout << "please enter two values: ";
int val1 = 0, val2 = 0;
cin >> val1 >> val2;
cout << "The sum of " << val1 << " and " << val2
<< " = " << val1 + val2 << "\n\n"
<< "More? Enter yes or no: ";
cin >> rsp;
} while (!rsp.empty() && rsp[0] != 'n');
Цикл начинается запросом у пользователя двух чисел. Затем выводится их сумма и следует запрос, желает ли пользователь суммировать далее. Ответ пользователя проверяется в условии. Если ввод пуст или начинается с n, цикл завершается. В противном случае цикл повторяется.
Поскольку условие не обрабатывается до окончания оператора или блока, цикл do while не позволяет определять переменные в условии.
do {
// ...
mumble(fоо) ;
} while (int foo = get_foo()); // ошибка: объявление в условии do
Если определить переменные в условии, то любое их использование произойдет прежде определения!
Упражнения раздела 5.4.4
Упражнение 5.18. Объясните каждый из следующих циклов. Исправьте все обнаруженные ошибки.
(a) do
int v1, v2;
cout << "Please enter two numbers to sum:";
if (cin >> v1 >> v2)
cout << "Sum is: " << v1 + v2 << endl;
while (cin);
(b) do {
// ...
} while (int ival = get_response());
(c) do {
int ival = get_response();
} while (ival);
Упражнение 5.19. Напишите программу, использующую цикл do while для циклического запроса у пользователя двух строк и указания, которая из них меньше другой.
5.5. Операторы перехода
Операторы перехода прерывают поток выполнения. Язык С++ предоставляет четыре оператора перехода: break, continue и goto, рассматриваемые в этой главе, и оператор return, который будет описан в разделе 6.3.
5.5.1. Оператор
break
Оператор break завершает ближайший окружающий оператор while, do while, for или switch. Выполнение возобновляется с оператора, следующего непосредственно за завершаемым оператором.
Оператор break может располагаться только в цикле или операторе switch (включая операторы или блоки, вложенные в эти циклы). Оператор break воздействует лишь на ближайший окружающий цикл или оператор switch.
string buf;
while (cin >> buf && !buf.empty()) {
switch(buf[0]) {
case '-':
// продолжать до первого пробела
for (auto it = buf.begin() + 1; it != buf.end(); ++it) {
if (*it == ' ')
break; // #1, выйти из цикла for
// ...
}
// break #1 передает управление сюда
// дальнейшая обработка случая '-'
break; // #2, выйти из оператора switch
case '+':
// ...
} // конец оператора switch
// break #2 передает управление сюда
} // конец оператора while
Оператор break с меткой #1 завершает цикл for в разделе case для случая дефиса. Он не завершает внешний оператор switch и даже не завершает обработку текущего случая. Выполнение продолжается с первого оператора после цикла for, который мог бы содержать дополнительный код обработки случая дефиса или оператор break, который завершает данный раздел.
Оператор break с меткой #2 завершает оператор switch, но не внешний цикл while. Выполнение кода после оператора break продолжает условие цикла while.
Упражнения раздела 5.5.1
Упражнение 5.20. Напишите программу, которая читает последовательность строк со стандартного устройства ввода до тех пор, пока не встретится повторяющееся слово или пока ввод слов не будет закончен. Для чтения текста по одному слову используйте цикл while. Для выхода из цикла при встрече двух совпадающих слов подряд используйте оператор break. Выведите повторяющееся слово, если оно есть, а в противном случае отобразите сообщение, свидетельствующее о том, что повторяющихся слов нет.
5.5.2. Оператор
continue
Операторcontinue прерывает текущую итерацию ближайшего цикла и немедленно начинает следующую. Оператор continue может присутствовать только в циклах for, while или do while, включая операторы или блоки, вложенные в такие циклы. Подобно оператору break, оператор continue во вложенном цикле воздействует только на ближайший окружающий цикл. Однако, в отличие от оператора break, оператор continue может присутствовать в операторе switch, только если он встроен в итерационный оператор.
Оператор continue прерывает только текущую итерацию; выполнение остается в цикле. В случае цикла while или do while выполнение продолжается с оценки условия. В традиционном цикле for выполнение продолжается в выражении заголовка. В серийном операторе for выполнение продолжается с инициализации управляющей переменной следующим элементом последовательности.
Следующий цикл читает со стандартного устройства ввода по одному слову за раз. Обработаны будут только те слова, которые начинаются с символа подчеркивания. Для любого другого значения текущая итерация заканчивается.
string buf;
while (cin >> buf && !buf.empty()) {
if (buf[0] != '_')
continue; // получить другой ввод
// все еще здесь? ввод начинается с '_', обработка buf...
}
Упражнения раздела 5.5.2
Упражнение 5.21. Переделайте программу из упражнения раздела 5.5.1 так, чтобы она искала дубликаты только тех слов, которые начинаются с прописной буквы.
5.5.3. Оператор
goto
Операторgoto обеспечивает безусловный переход к другому оператору в той же функции.
Не нужно использовать операторы goto. Они затрудняют и понимание, и изменение программ.
Оператор goto имеет следующий синтаксис:
goto метка ;
Метка (label) — это идентификатор, которым помечен оператор. Помеченный оператор (labeled statement) — это любой оператор, которому предшествует идентификатор, сопровождаемый двоеточием.
end: return; // помеченный оператор; может быть целью оператора goto
Метки независимы от имен, используемых для переменных и других идентификаторов. Следовательно, у метки может быть тот же идентификатор, что и у другой сущности в программе, не вступая в конфликт с другим одноименным идентификатором. Оператор goto и помеченный оператор, на который он передает управление, должны находиться в той же функции.
Подобно оператору switch, оператор goto не может передать управление из точки, где инициализированная переменная вышла из области видимости, в точку, где эта переменная находится в области видимости.
// ...
goto end;
int ix = 10; // ошибка: goto обходит определение инициализированной
// переменной
end:
// ошибка: код здесь мог бы использовать ix,
// но goto обошел ее объявление
ix = 42;
Переход назад за уже выполненное определение вполне допустим. Переходя назад к точке перед определением переменная приведет к ее удалению и повторному созданию.
// переход назад через определение
// инициализированной переменной приемлем
begin:
int sz = get_size();
if (sz <= 0) {
goto begin;
}
При выполнении оператора goto переменная sz удаляется, а затем она определяется и инициализируется снова, когда управление передается назад за ее определение после перехода к метке begin.
Упражнения раздела 5.5.3
Упражнение 5.22. Последний пример этого раздела, с переходом назад к метке begin, может быть написан лучше с использованием цикла. Перепишите код так, чтобы устранить оператор goto.
5.6. Блоки
try
и обработка исключений
Исключения (exception) — это аномалии времени выполнения, такие как потеря подключения к базе данных или ввод непредвиденных данных, которые нарушают нормальное функционирование программы. Реакция на аномальное поведение может быть одним из самых трудных этапов разработки любой системы.
Обработка исключений обычно используется в случае, когда некая часть программы обнаруживает проблему, с которой она не может справиться, причем проблема такова, что обнаружившая ее часть программы не может продолжить выполнение. В таких случаях обнаруживший проблему участок программы нуждается в способе сообщить о случившемся и о том, что он неспособен продолжить выполнение. Способ сообщения о проблеме не подразумевает знания о том, какая именно часть программы будет справляться с создавшейся ситуацией. Сообщив о случившемся, обнаружившая проблему часть кода прекращает работу.
Каждой части программы, способной передать исключение, соответствует другая часть, код которой способен обработать исключение, независимо от того, что произошло. Например, если проблема в недопустимом вводе, то часть обработки могла бы попросить пользователя ввести правильные данные. Если потеряна связь с базой данных, то часть обработки могла бы предупредить об этом пользователя.
Исключения обеспечивают взаимодействие частей программы, обнаруживающих проблему и решающих ее. Обработка исключений в языке С++ подразумевает следующее.
• Оператор throw используется частью кода обнаружившего проблему, с которой он не может справиться. Об операторе throw говорят, что он передает (raise) исключение.
• Блок try используется частью обработки исключения. Блок try начинается с ключевого слова try и завершается одной или несколькими директивами catch (catch clause). Исключения, переданные из кода, расположенного в блоке try, как правило, обрабатываются в одном из разделов catch. Поскольку разделы catch обрабатывают исключение, их называют также обработчиками исключений (exception handler).
• Набор определенных в библиотеке классов исключений (exception class) используется для передачи информации о произошедшем между операторами throw и соответствующими разделами catch.
В остальной части этого раздела три компонента обработки исключений рассматриваются последовательно. Более подробная информация об исключениях приведена в разделе 18.1.
5.6.1. Оператор
throw
Обнаруживающая часть программы использует оператор throw для передачи исключения. Он состоит из ключевого слова throw, сопровождаемого выражением. Выражение определяет тип передаваемого исключения. Оператор throw, как правило, завершается точкой с запятой, что делает его выражением.
Для примера вернемся к программе раздела 1.5.2, в которой суммируются два объекта класса Sales_item. Она проверяет, относятся ли обе прочитанные записи к одной книге. Если нет, она отображает сообщение об ошибке и завершает работу.
Sales_item item1, item2;
cin >> item1 >> item2;
// сначала проверить, представляют ли объекты item1 и item2
// одну и ту же книгу
if (item1.isbn() == item2.isbn()) {
cout << item1 + item2 << endl;
return 0; // свидетельство успеха
} else {
cerr << "Data must refer to same ISBN"
<< endl;
return -1; // свидетельство отказа
}
В более реалистичной программе суммирующая объекты часть могла бы быть отделена от части, обеспечивающей взаимодействие с пользователем. В таком случае проверяющую часть можно было бы переписать так, чтобы она передавала исключение, а не возвращала свидетельство отказа.
// сначала проверить, представляют ли объекты item1 и item2
// одну и ту же книгу
if (item1.isbn() != item2.isbn())
throw runtime_error("Data must refer to same ISBN");
// если управление здесь, значит, ISBN совпадают
cout << item1 + item2 << endl;
Если теперь ISBN окажутся разными, будет передан объект исключения типа runtime_error. Передача исключения завершает работу текущей функции и передает управление обработчику, способному справиться с этой ошибкой.
Тип runtime_error является одним из типов исключения, определенных в заголовке stdexcept стандартной библиотеки. Более подробная информация по этой теме приведена в разделе 5.6.3. Объект класса runtime_error следует инициализировать объектом класса string или символьной строкой в стиле С (см. раздел 3.5.4). Эта строка представляет дополнительную информацию о проблеме.
5.6.2. Блок
try
Блок try имеет следующий синтаксис:
try {
операторы_программы
} catch ( объявление_исключения ) {
операторы_обработчика
} catch ( объявление_исключения ) {
операторы_обработчика
} // ...
Блок try начинается с ключевого слова try, за которым следует блок кода, заключенный в фигурные скобки.
Блок try сопровождается одним или несколькими блоками catch. Блок catch состоит из трех частей: ключевого слова catch, объявления (возможно, безымянного) объекта в круглых скобках (называется объявлением исключения (exception declaration)) и операторного блока. Когда объявление исключения в блоке catch совпадает с исключением, выполняется связанный с ним блок. По завершении выполнения кода обработчика управление переходит к оператору, следующему непосредственно после него.
Операторы_программы в блоке try являются обычными программными операторами, реализующими ее логику. Подобно любым другим блокам кода, блоки try способны содержать любые операторы языка С++, включая объявления. Объявленные в блоке try переменные недоступны вне блока, в частности, они не доступны в блоках catch.
Создание обработчика
В приведенном выше примере, чтобы избежать суммирования двух объектов класса Sales_item, представляющих разные книги, использовался оператор throw. Предположим, что суммирующая объекты класса Sales_item часть программы отделена от части, взаимодействующей с пользователем. Эта часть могла бы содержать примерно такой код обработки исключения, переданного в блоке сложения.
while (cin >> item1 >> item2) {
try {
// код, который складывает два объекта класса Sales_item
// если при сложении произойдет сбой, код передаст
// исключение runtime_error
} catch (runtime_error err) {
// напомнить пользователю, что ISBN слагаемых объектов
// должны совпадать
cout << err.what()
<< "\nTry Again? Enter y or n" << endl;
char c;
cin >> с;
if (!cin || с == 'n')
break; // выход из цикла while
}
}
В блоке try расположена обычная логика программы. Это сделано потому, что данная часть программы способна передать исключение типа runtime_error.
Данный блок try обладает одним разделом catch, который обрабатывает исключение типа runtime_error. Операторы в блоке после ключевого слова catch определяют действия, выполняемые в случае, если код в блоке try передаст исключение runtime_error. В данном случае обработка подразумевает отображение сообщения об ошибке и запрос у пользователя разрешения на продолжение. Когда пользователь вводит символ 'n', цикл while завершается, в противном случае он продолжается и считывает два новых объекта класса Sales_item.
В сообщении об ошибке используется текст, возвращенный функцией err.what(). Поскольку известно, что классом объекта исключения err является runtime_error, нетрудно догадаться, что функция what() является членом (см. раздел 1.5.2) класса runtime_error. В каждом из библиотечных классов исключений определена функция-член what(), которая не получает никаких аргументов и возвращает символьную строку в стиле С (т.е. const char*). В случае класса runtime_error эта строка является копией строки, использованной при инициализации объекта класса runtime_error. Если описанный в предыдущем разделе код передаст исключение, то отображенное разделом catch сообщение об ошибке будет иметь следующий вид:
Data must refer to same ISBN
Try Again? Enter y or n
При поиске обработчика выполнение функций прерывается
В сложных системах программа может пройти через несколько блоков try прежде, чем встретится с кодом, который передает исключение. Например, в блоке try может быть вызвана функция, в блоке try которой содержится вызов другой функции с ее собственным блоком try, и т.д.
Поиск обработчика осуществляется по цепочке обращений в обратном порядке. Сначала поиск обработчика исключения осуществляется в той функции, в которой оно было передано. Если соответствующего раздела catch не найдено, работа функции завершается, а поиск продолжается в той функции, которая вызвала функцию, в которой было передано исключение. Если и здесь соответствующий раздел catch не найден, эта функция также завершается, а поиск продолжается по цепочке вызовов дальше, пока обработчик исключения соответствующего типа не будет найден.
Если соответствующий раздел catch так и не будет найден, управление перейдет к библиотечной функции terminate(), которая определена в заголовке exception. Поведение этой функции зависит от системы, но обычно она завершает выполнение программы.
Исключения, которые были переданы в программах, не имеющих блоков try, обрабатываются аналогично: в конце концов, без блоков try не может быть никаких обработчиков и ни для каких исключений, которые, однако, вполне могут быть переданы. В таком случае исключение приводит к вызову функции terminate(), которая (как правило) и завершает работу программы.
Внимание! Написание устойчивого к исключениям кода — довольно сложная задача
Важно понимать, что исключения прерывают нормальный поток программы. В месте, где происходит исключение, некоторые из действий, ожидаемых вызывающей стороной, могут быть выполнены, а другие нет. Как правило, пропуск части программы может означать, что объект останется в недопустимом или неполном состоянии, либо что ресурс не будет освобожден и т.д. Программы, которые правильно "зачищают" объекты во время обработки исключений, называют устойчивыми к исключениям (exception safe). Написание устойчивого к исключениям кода чрезвычайно сложно и практически не рассматривается в данном вводном курсе.
Некоторые программы используют исключения просто для завершения программы в случае проблем. Такие программы вообще не заботятся об устойчивости к исключениям.
Программы, которые действительно обрабатывают исключения и продолжают работу, должны постоянно знать, какое исключение может произойти и что программа должна делать для гарантии допустимости объектов, невозможности утечки ресурсов и восстановления программы в корректном состоянии.
Некоторые из наиболее популярных методик обеспечения устойчивости к исключениям здесь будут упомянуты. Однако читатели, программы которых требуют надежной обработки исключений, должны знать, что рассматриваемых здесь методик недостаточно для полного обеспечения устойчивости к исключениям.
5.6.3. Стандартные исключения
В библиотеке С++ определен набор классов, объекты которых можно использовать для передачи сообщений о проблемах в функциях, определенных в стандартной библиотеке. Эти стандартные классы исключений могут быть также использованы в программах, создаваемых разработчиком. Библиотечные классы исключений определены в четырех следующих заголовках.
• В заголовке exception определен общий класс исключения exception. Он сообщает только о том, что исключение произошло, но не предоставляет никакой дополнительной информации.
• В заголовке stdexcept определено несколько универсальных классов исключения (табл. 5.1).
• В заголовке new определен класс исключения bad_alloc, рассматриваемый в разделе 12.1.2.
• В заголовке type_info определен класс исключения bad_cast, рассматриваемый в разделе 19.2.
В классах exception, bad_alloc и bad_cast определен только стандартный конструктор (см. раздел 2.2.1), поэтому невозможно инициализировать объект этих типов.
Поведение исключений других типов прямо противоположно: их можно инициализировать объектом класса string или строкой в стиле С, однако значением по умолчанию их инициализировать нельзя. При создании объекта исключения любого из этих типов необходимо предоставить инициализатор. Этот инициализатор используется для предоставления дополнительной информации о произошедшей ошибке.
Таблица 5.1. Стандартные классы исключений, определенные в заголовке stdexcept
exception | Наиболее общий вид проблемы |
runtime_error | Проблема, которая может быть обнаружена только во время выполнения |
range_error | Ошибка времени выполнения: полученный результат превосходит допустимый диапазон значения |
overflow_error | Ошибка времени выполнения: переполнение регистра при вычислении |
underflow_error | Ошибка времени выполнения: недополнение регистра при вычислении |
logic_error | Ошибка в логике программы |
domain_error | Логическая ошибка: аргумент, для которого не существует результата |
invalid_argument | Логическая ошибка: неподходящий аргумент |
length_error | Логическая ошибка: попытка создать объект большего размера, чем максимально допустимый для данного типа |
out_of_range | Логическая ошибка: используемое значение вне допустимого диапазона |
В классах исключений определена только одна функция what(). Она не получает никаких аргументов и возвращает константный указатель на тип char. Это указатель на символьную строку в стиле С (см. раздел 3.5.4), содержащую текст описания переданного исключения.
Содержимое символьного массива (строки в стиле С), указатель на который возвращает функция what(), зависит от типа объекта исключения. Для типов, которым при инициализации передают строку класса string, функция what() возвращает строку. Что же касается других типов, то возвращаемое значение зависит от компилятора.
Упражнения раздела 5.6.3
Упражнение 5.23. Напишите программу, которая читает два целых числа со стандартного устройства ввода и выводит результат деления первого числа на второе.
Упражнение 5.24. Перепишите предыдущую программу так, чтобы она передавала исключение, если второе число — нуль. Проверьте свою программу с нулевым вводом, чтобы увидеть происходящее при отсутствии обработчика исключения.
Упражнение 5.25. Перепишите предыдущую программу так, чтобы использовать для обработки исключения блок try. Раздел catch должен отобразить сообщение и попросить пользователя ввести новое число и повторить код в блоке try.
Резюме
Язык С++ предоставляет довольно ограниченное количество операторов. Некоторые из них предназначены для управления потоком выполнения программы.
• Операторы while, for и do while позволяют реализовать итерационные циклы.
• Операторы if и switch позволяют реализовать условное выполнение.
• Оператор continue останавливает текущую итерацию цикла.
• Оператор break осуществляет принудительный выход из цикла или оператора switch.
• Оператор goto передает управление помеченному оператору.
• Операторы try и catch позволяют создать блок try, в который заключают операторы программы, потенциально способные передать исключение. Оператор catch начинает раздел обработчика исключения, код которого предназначен для реакции на исключение определенного типа.
• Оператор throw позволяет передать исключение, обрабатываемое в соответствующем разделе catch.
• Оператор return останавливает выполнение функции. (Подробней об этом — в главе 6.)
Кроме того, существуют операторы выражения и операторы объявления. Объявления и определения переменных были описаны в главе 2.
Термины
Блокtry. Блок, начинаемый ключевым словом try и содержащий один или несколько разделов catch. Если код в блоке try передаст исключение, а один из разделов catch соответствует типу этого исключения, то исключение будет обработано кодом данного обработчика. В противном случае исключение будет обработано во внешнем блоке try, но если и этого не произойдет, сработает функция terminate(), которая и завершит выполнение программы.
Блок (block). Последовательность любого количества операторов, заключенная в фигурные скобки. Блок операторов может быть использован везде, где ожидается один оператор.
Директиваcatch (catch clause). Состоит из ключевого слова catch, объявления исключения в круглых скобках и блока операторов. Код в разделе catch предназначен для обработки исключения, тип которого указан в объявлении.
Класс исключения (exception class). Набор определенных стандартной библиотекой классов, используемых для сообщения об ошибке. Универсальные классы исключений см. в табл. 5.1.
Меткаcase. Константное выражение (см. раздел 2.4.4), следующее за ключевым словом case в операторе switch. Метки case в том же операторе switch не могут иметь одинакового значения.
Меткаdefault. Метка оператора switch, соответствующая любому значению условия, не указанному в метках case явно.
Обработчик исключения (exception handler). Код, реагирующий на исключение определенного типа, переданное из другой части программы. Синоним термина директива catch.
Объявление исключения (exception declaration). Объявление в разделе catch. Определяет тип исключений, обрабатываемых данным обработчиком.
Операторbreak. Завершает ближайший вложенный цикл или оператор switch. Передает управление первому оператору после завершенного цикла или оператора switch.
Операторcontinue. Завершает текущую итерацию ближайшего вложенного цикла. Передает управление условию цикла while, оператору do или выражению в заголовке цикла for.
Операторdo while. Подобен оператору while, но условие проверяется в конце цикла, а не в начале. Тело цикла выполняется по крайней мере однажды.
Операторfor. Оператор цикла, обеспечивающий итерационное выполнение. Зачастую используется для повторения вычислений определенное количество раз.
Серийный операторfor (range for). Управляющий оператор, перебирающий значения указанной коллекции и выполняющий некую операцию с каждым из них.
Операторgoto. Оператор, осуществляющий безусловную передачу управления помеченному оператору в другом месте той же функции. Операторы goto нарушают последовательность выполнения операций программы, поэтому их следует избегать.
Операторif. Условное выполнение кода на основании значения в условии. Если условие истинно (значение true), тело оператора if выполняется, в противном случае управление переходит к оператору, следующему после него.
Операторif...else. Условное выполнение кода в разделе if или else, в зависимости от истинности значения условия.
Операторswitch. Оператор условного выполнения, который сначала вычисляет результат выражения, следующего за ключевым словом switch, а затем передает управление разделу case, метка которого совпадает с результатом выражения. Когда соответствующей метки нет, выполнение переходит к разделу default (если он есть) или к оператору, следующему за оператором switch, если раздела default нет.
Операторthrow. Оператор, прерывающий текущий поток выполнения. Каждый оператор throw передает объект, который переводит управление на ближайший раздел catch, способный обработать исключение данного класса.
Операторwhile. Оператор цикла, который выполняет оператор тела до тех пор, пока условие остается истинным (значение true). В зависимости от истинности значения условия оператор выполняется любое количество раз.
Оператор выражения (expression statement). Выражение завершается точкой с запятой. Оператор выражения обеспечивает выполнение действий в выражении.
Передача (raise, throwing). Выражение, которое прерывает текущий поток выполнения. Каждый оператор throw передает объект, переводящий управление на ближайший раздел catch, способный обработать исключение данного класса.
Помеченный оператор (labeled statement). Оператор, которому предшествует метка. Метка (label) — это идентификатор, сопровождаемый двоеточием. Метки используются независимо от других одноименных идентификаторов.
Потерянный операторelse (dangling else). Разговорный термин, используемый для описания проблемы, когда во вложенной конструкции операторов if больше, чем операторов else. В языке С++ оператор else всегда принадлежит ближайшему расположенному выше оператору if. Чтобы указать явно, какому из операторов if принадлежит конкретный оператор else, применяются фигурные скобки.
Пустой оператор (null statement). Пустой оператор представляет собой отдельный символ точки с запятой.
Составной оператор (compound statement). Синоним блока.
Управление потоком (flow of control). Управление последовательностью выполнения операций в программе.
Устойчивость к исключениям (exception safe). Термин, описывающий программы, которые ведут себя правильно при передаче исключения.
Функцияterminate(). Библиотечная функция, вызываемая в случае, если исключение так и не было обработано. Обычно завершает выполнение программы.
Глава 6
Функции
В этой главе описано, как объявлять и определять функции. Здесь также обсуждается передача функции аргументов и возвращение из них полученных значений. В языке С++ функции могут быть перегружены, т.е. то же имя может быть использовано для нескольких разных функций. Мы рассмотрим и то, как перегрузить функции, и то, как компилятор выбирает из нескольких перегруженных функций ее соответствующую версию для конкретного вызова. Завершается глава описанием указателей на функции.
Функция (function) — это именованный блок кода. Запуск этого кода на выполнение осуществляется при вызове функции. Функция может получать любое количество аргументов и (обычно) возвращает результат. Функция может быть перегружена, следовательно, то же имя может относиться к нескольким разным функциям.
6.1. Основы функций
Определение функции (function definition) обычно состоит из типа возвращаемого значения (return type), имени, списка параметров (parameter) и тела функции. Параметры определяются в разделяемом запятыми списке, заключенном в круглые скобки. Выполняемые функцией действия определяются в блоке операторов (см. раздел 5.1), называемом телом функции (function body).
Для запуска кода функции используется оператор вызова (call operator), представляющий собой пару круглых скобок. Оператор вызова получает выражение, являющееся функцией или указателем на функцию. В круглых скобках располагается разделяемый запятыми список аргументов (argument). Аргументы используются для инициализации параметров функции. Тип вызываемого выражения — это тип возвращаемого значения функции.
Создание функции
В качестве примера напишем функцию вычисления факториала заданного числа. Факториал числа n является произведением чисел от 1 до n . Факториал 5, например, равен 120:
1 * 2 * 3 * 4 * 5 = 120
Эту функцию можно определить следующим образом:
// факториал val равен
// val * (val - 1) * (val - 2) ... * ((val - (val - 1)) * 1)
int fact(int val) {
int ret = 1; // локальная переменная для содержания результата по
// мере его вычисления
while (val > 1)
ret *= val--; // присвоение ret произведения ret * val
// и декремент val
return ret; // возвратить результат
}
Функции присвоено имя fact. Она получает один параметр типа int и возвращает значение типа int. В цикле while вычисляется факториал с использованием постфиксного оператора декремента (см. раздел 4.5), уменьшающего значение переменной val на 1 при каждой итерации. Оператор return выполняется в конце функции fact и возвращает значение переменной ret.
Вызов функции
Чтобы вызвать функцию fact(), следует предоставить ей значение типа int. Результатом вызова также будет значение типа int:
int main() {
int j = fact(5); // j равно 120, т.е. результату fact(5)
cout << "5! is " << j << endl;
return 0;
}
Вызов функции осуществляет два действия: он инициализирует параметры функции соответствующими аргументами и передает управление коду этой функции. При этом выполнение вызывающей (calling) функции приостанавливается и начинается выполнение вызываемой (called) функции.
Выполнение функции начинается с неявного определения и инициализации ее параметров. Таким образом, когда происходит вызов функции fact(), сначала создается переменная типа int по имени val. Эта переменная инициализируется аргументом, предоставленным при вызове функции fact(), которым в данном случае является 5.
Выполнение функции заканчивается оператором return. Как и вызов функции, оператор return осуществляет два действия: возвращает значение (если оно есть) и передает управление назад вызывающей функции. Возвращенное функцией значение используется для инициализации результата вызывающего выражения. Выполнение продолжается с остальной частью выражения, в составе которого осуществлялся вызов. Таким образом, вызов функции fact() эквивалентен следующему:
int val = 5; // инициализировать val из литерала 5
int ret = 1; // код из тела функции fact
while (val > 1)
ret *= val--;
int j = ret; // инициализировать j копией ret
Параметры и аргументы
Аргументы — это инициализаторы для параметров функции. Первый аргумент инициализирует первый параметр, второй аргумент инициализирует второй параметр и т.д. Хотя порядок инициализации параметров аргументами известен, порядок обработки аргументов не гарантирован (см. раздел 4.1.3). Компилятор может вычислять аргументы в любом порядке по своему предпочтению.
Тип каждого аргумента должен совпадать с типом соответствующего параметра, как и тип любого инициализатора должен совпадать с типом объекта, который он инициализирует. Следует передать точно такое же количество аргументов, сколько у функции параметров. Поскольку каждый вызов гарантированно передаст столько аргументов, сколько у функции параметров, последние всегда будут инициализированы.
Поскольку у функции fact() один параметр типа int, при каждом ее вызове следует предоставить один аргумент, который может быть преобразован в тип int (см. раздел 4.11):
fact("hello"); // ошибка: неправильный тип аргумента
fact(); // ошибка: слишком мало аргументов
fact(42, 10, 0); // ошибка: слишком много аргументов
fact(3.14); // ok: аргумент преобразуется в int
Первый вызов терпит неудачу потому, что невозможно преобразование значения типа const char* в значение типа int. Второй и третий вызовы передают неправильные количества аргументов. Функцию fact() следует вызывать с одним аргументом; ее вызов с любым другим количеством аргументов будет ошибкой. Последний вызов допустим, поскольку значение типа double преобразуется в значение типа int. В этом случае аргумент неявно преобразуется в тип int (с усечением). После преобразования этот вызов эквивалентен следующему:
fact(3);
Список параметров функции
Список параметров функции может быть пустым, но он не может отсутствовать. При определении функции без параметров обычно используют пустой список параметров. Для совместимости с языком С можно также использовать ключевое слово void, чтобы указать на отсутствие параметров:
void f1() { /* ... */ } // неявно указанный пустой список параметров
void f2(void) { /* ... */ } // явно указанный пустой список параметров
Список параметров, как правило, состоит из разделяемого запятыми списка параметров, каждый из которых выглядит как одиночное объявление. Даже когда типы двух параметров одинаковы, объявление следует повторить:
int f3(int v1, v2) { /* ... */ } // ошибка
int f4(int v1, int v2) { /* ... */} // ok
Параметры не могут иметь одинаковые имена. Кроме того, локальные переменные даже в наиболее удаленной области видимости в функции не могут использовать имя, совпадающее с именем любого параметра.
Имена в определении функций не обязательны, но все параметры обычно именуют. Поэтому у каждого параметра обычно есть имя. Иногда у функций есть не используемые параметры. Такие параметры зачастую оставляют безымянными, указывая, что они не используются. Наличие безымянного параметра не изменяет количество аргументов, которые следует передать при вызове. Аргумент при вызове должен быть предоставлен для каждого параметра, даже если он не используется.
Тип возвращаемого значения функции
В качестве типа возвращаемого значения функции применимо большинство типов. В частности, типом возвращаемого значения может быть void, это означает, что функция не возвращает значения. Но типом возвращаемого значения не может быть массив (см. раздел 3.5) или функция. Однако функция может возвратить указатель на массив или функцию. Определение функции, возвращающей указатель (или ссылку) на массив, рассматривается в разделе 6.3.3, а указателя на функцию — в разделе 6.7.
Упражнения раздела 6.1
Упражнение 6.1. В чем разница между параметром и аргументом?
Упражнение 6.2. Укажите, какие из следующих функций ошибочны и почему. Предложите способ их исправления.
(a) int f() {
string s;
// ...
return s;
}
(b) f2(int i) { /* ... */ }
(c) int calc(int v1, int v1) /* ... */ }
(d) double square(double x) return x * x;
Упражнение 6.3. Напишите и проверьте собственную версию функции fact().
Упражнение 6.4. Напишите взаимодействующую с пользователем функцию, которая запрашивает число и вычисляет его факториал. Вызовите эту функцию из функции main().
Упражнение 6.5. Напишите функцию, возвращающую абсолютное значение ее аргумента.
6.1.1. Локальные объекты
В языке С++ имя имеет область видимости (см. раздел 2.2.4), а объекты — продолжительность существования (object lifetime). Обе эти концепции важно понимать.
• Область видимости имени — это часть текста программы, в которой имя видимо.
• Продолжительность существования объекта — это время при выполнении программы, когда объект существует.
Как уже упоминалось, тело функции — это блок операторов. Как обычно, блок формирует новую область видимости, в которой можно определять переменные. Параметры и переменные, определенные в теле функции, называются локальными переменными (local variable). Они являются локальными для данной функции и скрывают (hide) объявления того же имени во внешней области видимости.
Объекты, определенные вне любой из функций, существуют на протяжении выполнения программы. Такие объекты создаются при запуске программы и не удаляются до ее завершения. Продолжительность существования локальной переменной зависит от того, как она определена.
Автоматические объекты
Объекты, соответствующие обычным локальным переменным, создаются при достижении процессом выполнения определения переменной в функции. Они удаляются, когда процесс выполнения достигает конца блока, в котором определена переменная. Объекты, существующие только во время выполнения блока, известны как автоматические объекты (automatic object). После выхода процесса выполнения из блока значения автоматических объектов, созданных в этом блоке, неопределенны.
Параметры — это автоматические объекты. Место для параметров резервируется при запуске функции. Параметры определяются в пределах тела функции. Следовательно, они удаляются по завершении функции.
Автоматические объекты, соответствующие параметрам функции, инициализируются аргументами, переданными функции. Автоматические объекты, соответствующие локальным переменным, инициализируются, если их определение содержит инициализатор. В противном случае они инициализируются значением по умолчанию (см. раздел 2.2.1), а это значит, что значения неинициализированных локальных переменных встроенного типа неопределенны.
Локальные статические объекты
Иногда полезно иметь локальную переменную, продолжительность существования которой не прерывается между вызовами функции. Чтобы получить такие объекты, при определении локальной переменной используют ключевое слово static. Каждый локальный статический объект (local static object) инициализируется прежде, чем выполнение достигнет определения объекта. Локальная статическая переменная не удаляется по завершении функции; она удаляется по завершении программы.
В качестве простого примера рассмотрим функцию, подсчитывающую количество своих вызовов:
size_t count_calls() {
static size_t ctr = 0; // значение сохраняется между вызовами
return ++ctr;
}
int main() {
for (size_t i = 0; i != 10; ++i)
cout << count_calls() << endl;
return 0;
}
Эта программа выводит числа от 1 до 10 включительно.
Прежде чем процесс выполнения впервые достигнет определения переменной ctr, она уже будет создана и получит исходное значение 0. Каждый вызов осуществляет инкремент переменной ctr и возвращает ее новое значение. При каждом запуске функции count_calls() переменная ctr уже существует и имеет некое значение, возможно, оставленное последним вызовом функции. Поэтому при втором вызове значением переменной ctr будет 1, при третьем — 2 и т.д.
Если у локальной статической переменной нет явного инициализатора, она инициализируется значением по умолчанию (см. раздел 3.3.1), следовательно, локальные статические переменные встроенного типа инициализируются нулем.
Упражнения раздела 6.1.1
Упражнение 6.6. Объясните различия между параметром, локальной переменной и локальной статической переменной. Приведите пример функции, в которой каждая из них могла бы быть полезной.
Упражнение 6.7. Напишите функцию, которая возвращает значение 0 при первом вызове, а при каждом последующем вызове возвращает последовательно увеличивающиеся числа.
6.1.2. Объявление функций
Как и любое другое имя, имя функции должно быть объявлено прежде, чем его можно будет использовать. Подобно переменным (см. раздел 2.2.2), функция может быть определена только однажды, но объявлена может быть многократно. За одним исключением, которое будет описано в разделе 15.3, можно объявить функцию, которая не определяется до тех пор, пока она не будет использована.
Объявление функции подобно ее определению, но у объявления нет тела функции. В объявлении тело функции заменяет точка с запятой.
Поскольку у объявления функции нет тела, нет никакой необходимости в именах параметров. Поэтому имена параметров зачастую отсутствуют в объявлении. Хоть имена параметров и не обязательны, они зачастую используются, чтобы помочь пользователям функции понять ее назначение:
// имена параметров указывают, что итераторы обозначают диапазон
// выводимых значений
void print(vector
vector
Эти три элемента объявления (тип возвращаемого значения, имя функции и тип параметров) описывают интерфейс (interface) функции. Они задают всю информацию, необходимую для вызова функции. Объявление функции называют также прототипом функции (function prototype).
Объявления функций находятся в файлах заголовка
Напомним, что объявления переменных располагают в файлах заголовка (см. раздел 2.6.3), а определения — в файлах исходного кода. По тем же причинам функции должны быть объявлены в файлах заголовка и определены в файлах исходного кода.
Весьма соблазнительно (и вполне допустимо) размещать объявления функций непосредственно в каждом файле исходного кода, который использует функцию. Однако такой подход утомителен и приводит к ошибкам. Помещая объявления функций в файлы заголовка, можно гарантировать, что все объявления данной функции будут одинаковы. Если необходимо изменить интерфейс функции, достаточно модифицировать его только в одном объявлении.
Файл исходного кода, в котором функция определена, должен подключать заголовок, в котором функция объявлена. Так компилятор сможет проверить соответствие определения и объявления.
Упражнения раздела 6.1.2
Упражнение 6.8. Напишите файл заголовка по имени Chapter6.h, содержащий объявления функций, написанных для упражнений раздела 6.1
6.1.3. Раздельная компиляция
По мере усложнения программ возникает необходимость хранить различные части программы в отдельных файлах. Например, функции, написанные для упражнений раздела 6.1, можно было бы сохранить в одном файле, а код, использующий их, в других файлах исходного кода. Язык С++ позволяет разделять программы на логические части, предоставляя средство, известное как раздельная компиляция (separate compilation). Раздельная компиляция позволяет разделять программы на несколько файлов, каждый из которых может быть откомпилирован независимо.
Компиляция и компоновка нескольких файлов исходного кода
Предположим, например, что определение функции fact() находится в файле fact.cc, а ее объявление — в файле заголовка Chapter6.h. Файл fact.cc, как и любой другой файл, использующий эту функцию, будет включать заголовок Chapter6.h. Функцию main(), вызывающую функцию fact(), будем хранить в еще одном файле factMain.cc.
Чтобы создать исполнимый файл (executable file), следует указать компилятору, где искать весь используемый код. Эти файлы можно было бы откомпилировать следующим образом:
$ CC factMain.cc fact.cc # generates factMain.exe or a.out
$ CC factMain.cc fact.cc -o main # generates main or main.exe
где CC — имя компилятора; $ — системная подсказка; # — начало комментария командной строки. Теперь можно запустить исполняемый файл, который выполнит нашу функцию main().
Если бы изменен был только один из наших файлов исходного кода, то перекомпилировать достаточно было бы только тот файл, который был фактически изменен. Большинство компиляторов предоставляет возможность раздельной компиляции каждого файла. Обычно этот процесс создает файл с расширением .obj (на Windows) или .o (на UNIX), указывающим, что этот файл содержит объектный код (object code).
Компилятор позволяет скомпоновать (link) объектные файлы (object file) и получить исполняемый файл. На системе авторов раздельная компиляция программы осуществляется следующим образом:
$ CC -с factMain.cc # generates factMain.o
$ CC -c fact.cc # generates fact.o
$ CC factMain.o fact.o # generates factMain.exe or a.out
$ CC factMain.o fact.o -o main # generates main or main.exe
Сверьтесь с руководством пользователя вашего компилятора, чтобы уточнить, как именно компилировать и запускать программы, состоящие из нескольких файлов исходного кода.
Упражнения раздела 6.1.3
Упражнение 6.9. Напишите собственные версии файлов fact.cc и factMain.cc. Эти файлы должны включать заголовок Chapter6.h из упражнения предыдущего раздела. Используйте эти файлы чтобы понять, как ваш компилятор обеспечивает раздельную компиляцию.
6.2. Передача аргументов
Как уже упоминалось, при каждом вызове функции ее параметры создаются заново. Используемое для инициализации параметра значение предоставляет соответствующий аргумент, переданный при вызове.
Параметры инициализируются точно так же, как и обычные переменные.
Как и у любой другой переменной, взаимодействие параметра и его аргумента определяет тип параметра. Если параметр — ссылка (см. раздел 2.3.1), то параметр привязывается к своему аргументу. В противном случае, значение аргумента копируется.
Когда параметр — ссылка, говорят, что его аргумент передается по ссылке (pass by reference) или что функция вызывается по ссылке (call by reference). Подобно любой другой ссылке, ссылочный параметр — это только псевдоним объекта, к которому он привязан, т.е. ссылочный параметр — псевдоним своего аргумента.
Когда значение аргумента копируется, параметр и аргумент — независимые объекты. Говорят, что такие аргументы передаются по значению (pass by value) или что функция вызывается по значению (call by value).
6.2.1. Передача аргумента по значению
При инициализации переменной не ссылочного типа значение инициализатора копируется. Изменения значения переменной никак не влияют на инициализатор:
int n = 0; // обычная переменная типа int
int i = n; // i - копия значения переменной n
i = 42; // значение i изменилось, значение n - нет
Передача аргумента по значению осуществляется точно так же; что бы функция не сделала с параметром, на аргумент это не повлияет. Например, в функции fact() (см. раздел 6.1) происходит декремент параметра val:
ret *= val--; // декремент значения val
Хотя функция fact() изменила значение val, это изменение никак не повлияло на переданный ей аргумент. Вызов fact(i) не изменяет значение переменной i.
Параметры указателя
Указатели (см. раздел 2.3.2) ведут себя, как любой не ссылочный тип. При копировании указателя его значение копируется. После создания копии получается два отдельных указателя. Однако указатель обеспечивает косвенный доступ к объекту, на который он указывает. Значение этого объекта можно изменить при помощи указателя (см. раздел 2.3.2):
int n = 0, i = 42;
int *p = &n, *q = &i; // p указывает на n; q указывает на i
*p = 42; // значение n изменилось, значение p - нет
p = q; // теперь p указывает на i; значения i и n
// неизменны
То же поведение характерно для указателей, являющихся параметрами:
// функция получает указатель и обнуляет значение, на которое он
// указывает
void reset(int *ip) {
*ip = 0; // изменяет значение объекта, на который указывает ip
ip = 0; // изменяет только локальную копию ip; аргумент неизменен
}
После вызова функции reset() объект, на который указывает аргумент, будет обнулен, но сам аргумент-указатель не изменится:
int i = 42;
reset(&i); // изменяет значение i, но не адрес
cout << "i = " << i << endl; // выводит i = 0
Программисты, привыкшие к языку С, зачастую используют параметры в виде указателей для доступа к объектам вне функции. В языке С++ для этого обычно используют ссылочные параметры.
Упражнения раздела 6.2.1
Упражнение 6.10. Напишите, используя указатели, функцию, меняющую значения двух целых чисел. Проверьте функцию, вызвав ее и отобразив измененные значения.
6.2.2. Передача аргумента по ссылке
Напомним, что операции со ссылками — это фактически операции с объектами, к которым они привязаны (см. раздел 2.3.1):
int n = 0, i = 42;
int &r = n; // r привязан к n (т.е. r - другое имя для n)
r = 42; // теперь n = 42
r = i; // теперь n имеет то же значение, что и i
i = r; // i имеет то же значение, что и n
Ссылочные параметры используют это поведение. Обычно они применяются, чтобы позволить функции изменить значение одного или нескольких аргументов.
Для примера можно переписать программу reset из предыдущего раздела так, чтобы использовать ссылку вместо указателя:
// функция, получающая ссылку на объект типа int и обнуляющая его
void reset(int &i) // i - только другое имя объекта, переданного
// на обнуление
{
i = 0; // изменяет значение объекта, на который ссылается i
}
Подобно любой другой ссылке, ссылочный параметр связывается непосредственно с объектом, которым он инициализируется. При вызове этой версии функции reset() параметр i будет связан с любым переданным ей объектом типа int. Как и с любой ссылкой, изменения, сделанные с параметром i, осуществляются с объектом, на который она ссылается. В данном случае этот объект — аргумент функции reset().
Когда вызывается эта версия функции reset(), объект передается непосредственно; поэтому нет никакой необходимости в передаче его адреса:
int j = 42;
reset(j); // j передается по ссылке; значение в j изменяется
cout << "j = " << j << endl; // выводит j = 0
В этом вызове параметр i — это только другое имя переменной j. Любое использование параметра i в функции reset() фактически является использованием переменной j.
Использование ссылки во избежание копирования
Копирование объектов больших классов или больших контейнеров снижает эффективность программы. Кроме того, некоторые классы (включая классы IO) не допускают копирования. Для работы с объектами, тип которых не допускает копирования, функции должны использовать ссылочные параметры.
В качестве примера напишем функцию сравнения длин двух строк. Поскольку строки могут быть очень длинными и их копирования желательно избежать, сделаем параметры ссылками. Так как сравнение двух строк не подразумевает их изменения, сделаем ссылочные параметры константами (см. раздел 2.4.1):
// сравнить длины двух строк
bool isShorter(const string &s1, const string &s2) {
return s1.size() < s2.size();
}
Как будет продемонстрировано в разделе 6.2.3, для не подлежащих изменению ссылочных параметров функции должны использовать ссылки на константу.
Ссылочные параметры, которые не изменяются в функции, должны быть объявлены как const.
Использование ссылочных параметров для возвращения дополнительной информации
Функция может возвратить только одно значение. Но что если функции нужно возвратить больше одного значения? Ссылочные параметры позволяют возвратить несколько результатов. В качестве примера определим функцию find_char(), которая возвращает позицию первого вхождения заданного символа в строке. Функция должна также возвращать количество этих символов в строке.
Как же определить функцию, возвращающую и позицию, и количество вхождений? Можно было бы определить новый тип, содержащий позицию и количество. Однако куда проще передать дополнительный ссылочный аргумент, содержащий количество вхождений:
// возвращает индекс первого вхождения с в s
// ссылочный параметр occurs содержит количество вхождений
string::size_type find_char(const string &s, char c,
string::size_type &occurs) {
auto ret = s.size(); // позиция первого вхождения, если оно есть
occurs = 0; // установить параметр количества вхождений
for (decltype(ret) i = 0; i != s.size(); ++i) {
if (s[i] == c) {
if (ret == s.size())
ret = i; // запомнить первое вхождение с
++occurs; // инкремент счетчика вхождений
}
}
return ret; // количество возвращается неявно в параметре occurs
}
Когда происходит вызов функции find_char(), ей передаются три аргумента: строка, в которой осуществляется поиск, искомый символ и объект типа size_type (раздел 3.2.2), содержащий счетчик вхождений. Если s является объектом класса string, a ctr — объектом типа size_type, то функцию find_char() можно вызвать следующим образом:
auto index = find_char(s, 'o', ctr);
После вызова значением объекта ctr будет количество вхождений символа о, a index укажет на его первое вхождение, если оно будет. В противном случае значение index будет равно s.size(), a ctr — нулю.
Упражнения раздела 6.2.2
Упражнение 6.11. Напишите и проверьте собственную версию функции reset(), получающую ссылку.
Упражнение 6.12. Перепишите программу из упражнения 6.10 раздела 6.2.1 так, чтобы использовать ссылки вместо указателей при смене значений двух целочисленных переменных. Какая из версий, по вашему, проще в использовании и почему?
Упражнение 6.13. Если Т — имя типа, объясните различие между функцией, объявленной как void f(Т) и как void f(Т&).
Упражнение 6.14. Приведите пример, когда параметр должен быть ссылочным типом. Приведите пример случая, когда параметр не должен быть ссылкой.
Упражнение 6.15. Объясните смысл каждого из типов параметров функции find_char(). В частности, почему s — ссылка на константу, a occurs — простая ссылка? Почему эти параметры ссылочные, а параметр с типа char нет? Что будет, сделай мы s простой ссылкой? Что если occurs сделать константной ссылкой?
6.2.3. Константные параметры и аргументы
При использовании параметров, являющихся константой, следует помнить об обсуждении спецификатора const верхнего уровня из раздела 2.4.3. Как упоминалось в этом разделе, спецификатор const верхнего уровня — это тот спецификатор, который относится непосредственно к объекту:
const int ci = 42; // нельзя изменить ci; const верхнего уровня
int i = ci; // ok: при копировании ci спецификатор const
// верхнего уровня игнорируется
int * const p = &i; // const верхнего уровня; нельзя присвоить p
*p = 0; // ok: изменение при помощи p возможно; i теперь 0
Как и при любой другой инициализации, при копировании аргумента для инициализации параметра спецификаторы const верхнего уровня игнорируются. В результате спецификатор const верхнего уровня для параметров игнорируется. Параметру, у которого есть спецификатор const верхнего уровня, можно передать и константный, и неконстантный объект:
void fcn(const int i) { /* fcn может читать, но не писать в i */ }
Функцию fcn() можно вызвать, передав ей аргумент типа const int или обычного типа int. Тот факт, что спецификаторы const верхнего уровня игнорируются у параметра, может иметь удивительные последствия:
void fcn(const int i) { /* fcn может читать, но не писать в i */ }
void fcn(int i) { /* ... */ } // ошибка: переопределяет fcn(int)
В языке С++ можно определить несколько разных функций с одинаковым именем. Однако это возможно только при достаточно большом различии их списков параметров. Поскольку спецификаторы const верхнего уровня игнорируются, мы можем передать те же типы любой версии функции fcn(). Вторая версия функции fcn() является ошибкой. Несмотря на внешний вид, ее список параметров не отличается от списка первой версии функции fcn().
Параметры в виде указателей или ссылок и константность
Поскольку параметры инициализируются так же, как и переменные, имеет смысл напомнить общие правила инициализации. Можно инициализировать объект со спецификатором const нижнего уровня неконстантным объектом, но не наоборот, а простую ссылку следует инициализировать объектом того же типа.
int i = 42;
const int *cp = &i; // ok: но cp не может изменить i (раздел 2.4.2)
const int &r = i; // ok: но r не может изменить i (раздел 2.4.1)
const int &r2 = 42; // ok: (раздел 2.4.1)
int *p = cp; // ошибка: типы p и cp не совпадают (раздел 2.4.2)
int &r3 = r; // ошибка: типы r3 и r не совпадают (раздел 2.4.1)
int &r4 = 42; // ошибка: нельзя инициализировать простую ссылку из
// литерала (раздел 2.3.1)
Те же правила инициализации относятся и к передаче параметров:
int i = 0;
const int ci = i;
string::size_type ctr = 0;
reset(&i); // вызывает версию функции reset с параметром типа int*
reset(&ci); // ошибка: нельзя инициализировать int* из указателя на
// объект const int
reset(i); // вызывает версию функции reset с параметром типа int&
reset(ci); // ошибка: нельзя привязать простую ссылку к константному
// объекту ci
reset(42); // ошибка: нельзя привязать простую ссылку к литералу
reset(ctr); // ошибка: типы не совпадают; ctr имеет беззнаковый тип
// ok: первый параметр find_char является ссылкой на константу
find_char("Hello World!", 'o', ctr);
Ссылочную версию функции reset() (см. раздел 6.2.2) можно вызвать только для объектов типа int. Нельзя передать литерал, выражение, результат которого будет иметь тип int, объект, который требует преобразования, или объект типа const int. Точно так же версии функции reset() с указателем можно передать только объект типа int* (см. раздел 6.2.1). С другой стороны, можно передать строковый литерал как первый аргумент функции find_char() (см. раздел 6.2.2). Ссылочный параметр этой функции — ссылка на константу, и можно инициализировать ссылки на константу из литералов.
#magnify.png По возможности используйте ссылки на константы
Весьма распространена ошибка, когда не изменяемые функцией параметры определяют как простые ссылки. Это создает у вызывающей стороны функции ложное впечатление, что функция могла бы изменить значение своего аргумента. Кроме того, использование ссылки вместо ссылки на константу неоправданно ограничивает типы аргументов, применяемые функцией. Как уже упоминалось, нельзя передать константный объект, литерал или требующий преобразования объект как простой ссылочный параметр.
В качестве примера рассмотрим функцию find_char() из раздела 6.2.2. Строковый параметр этой функции правильно сделан ссылкой на константу. Если бы этот параметр был определен как string&:
// ошибка: первый параметр должен быть const string&
string::size_type find_char(string &s, char c,
string::size_type &occurs);
то вызвать ее можно было бы только для объекта класса string, так что
find_char("Hello World", 'o', ctr);
привело бы к неудаче во времени компиляции.
Более того, эту версию функции find_char() нельзя использовать из других функций, которые правильно определяют свои параметры как ссылки на константу. Например, мы могли бы использовать функцию find_char() в функции, которая определяет, является ли строка предложением:
bool is_sentence(const string &s) {
// если в конце s есть точка, то строка s - предложение
string::size_type ctr = 0;
return find_char(s, ctr) == s.size() - 1 && ctr == 1;
}
Если бы функция find_char() получала простую ссылку string?, то этот ее вызов привел бы к ошибке при компиляции. Проблема в том, что s — ссылка на const string, но функция find_char() была неправильно определена как получающая простую ссылку.
Было бы заманчиво попытаться исправить эту проблему, изменив тип параметра в функции is_sentence(). Но это только распространит ошибку, так как вызывающая сторона функции is_sentence() сможет передавать только неконстантные строки.
Правильный способ решения этой проблемы — исправить параметр функции find_char(). Если невозможно изменить функцию find_char(), определите локальную копию строки s в функции is_sentence() и передавайте эту строку функции find_char().
Упражнения раздела 6.2.3
Упражнение 6.16. Несмотря на то что следующая функция допустима, она менее полезна, чем могла бы быть. Выявите и исправьте ограничение этой функции:
bool is_empty(string& s) { return s.empty(); }
Упражнение 6.17. Напишите функцию, определяющую, содержит ли строка какие-нибудь заглавные буквы. Напишите функцию, переводящую всю строку в нижний регистр. Использованные в этих функциях параметры имеют тот же тип? Если да, то почему? Если нет, то тоже почему?
Упражнение 6.18. Напишите объявления для каждой из следующих функций. Написав объявления, используйте имя функции для обозначения того, что она делает.
(a) Функция compare() возвращает значение типа bool и получает два параметра, являющиеся ссылками на класс matrix.
(b) Функция change_val() возвращает итератор vector
Упражнение 6.19. С учетом следующего объявления определите, какие вызовы допустимы, а какие нет. Объясните, почему они недопустимы.
double calc(double);
int count(const string &, char);
int sum(vector
vector
(a) calc(23.4, 55.1); (b) count("abcda", 'a');
(c) calc(66); (d) sum(vec.begin(), vec.end(), 3.8);
Упражнение 6.20. Когда ссылочные параметры должны быть ссылками на константу? Что будет, если сделать параметр простой ссылкой, когда это могла быть ссылка на константу?
6.2.4. Параметры в виде массива
Массивы обладают двумя особенностями, влияющими на определение и использование функций, работающих с массивами: массив нельзя скопировать (см. раздел 3.5.1), имя массива при использовании автоматически преобразуется в указатель на его первый элемент (см. раздел 3.5.3). Поскольку копировать массив нельзя, его нельзя передать функции по значению. Так как имя массива автоматически преобразуется в указатель, при передаче массива функции фактически передается указатель на его первый элемент.
Хотя передать массив по значению нельзя, вполне можно написать параметр, который выглядит как массив:
// несмотря на внешний вид,
// эти три объявления функции print эквивалентны
// у каждой функции есть один параметр типа const int*
void print(const int*);
void print(const int[]); // демонстрация намерения получить массив
void print(const int[10]); // размерность только для документирования
Независимо от внешнего вида, эти объявления эквивалентны: в каждом объявлена функция с одним параметром типа const int*. Когда компилятор проверяет вызов функции print(), он выясняет только то, что типом аргумента является const int*:
int i = 0, j[2]А также для временного отключения больших фрагментов кода при отладке. — Примеч. ред .
= {0, 1};
print(&i); // ok: &i - int*
print(j); // ok: j преобразуется в int*, указывающий на j[0]
Если передать массив функции print(), то этот аргумент автоматически преобразуется в указатель на первый элемент в массиве; размер массива не имеет значения.
Подобно любому коду, который использует массивы, функции, получающие в качестве параметров массив, должны гарантировать невыход за пределы его границ.
Поскольку массивы передаются как указатели, их размер функции обычно неизвестен. Они должны полагаться на дополнительную информацию, предоставляемую вызывающей стороной. Для управления параметрами указателя обычно используются три подхода.
Использование маркера для определения продолжения массива
Первый подход к управлению аргументами в виде массива требует, чтобы массив сам содержал маркер конца. Примером этого подхода являются символьные строки в стиле С (см. раздел 3.5.4). Строки в стиле С хранятся в символьных массивах, последний символ которых является нулевым. Функции, работающие со строками в стиле С, прекращают обработку массива, когда встречают нулевой символ:
void print(const char *cp) {
if (cp) // если cp не нулевой указатель
while (*cp) // пока указываемый символ не является нулевым
cout << *cp++; // вывести символ и перевести указатель
}
Это соглашение хорошо работает с данными, где есть очевидное значение конечного маркера (такое, как нулевой символ), который не встречается в обычных данных. Это работает значительно хуже с такими данными, как целые числа, где каждое значение в диапазоне вполне допустимо.
Использование соглашения стандартной библиотеки
Второй подход обычно используется для управления аргументами в виде массива при передаче указателей на первый и следующий после последнего элемент массива. Подобный подход используется в стандартной библиотеке. Подробно этот стиль программирования обсуждается в части II. Используя этот подход, элементы массива можно отобразить следующим образом:
void print(const int *beg, const int *end) {
// вывести все элементы, начиная с beg и до, но не включая, end
while (beg != end)
cout << *beg++ << endl; // вывести текущий элемент
// и перевести указатель
}
Для вывода текущего элемента и перевода указателя beg на следующий элемент массива цикл while использует операторы обращения к значению и постфиксного инкремента (см. раздел 4.5). Цикл останавливается, когда beg становится равен end.
При вызове этой функции передаются два указателя: один на первый подлежащий отображению элемент и один на элемент после последнего:
int j[2]А также для временного отключения больших фрагментов кода при отладке. — Примеч. ред .
= {0, 1};
// j преобразуется в указатель на первый элемент массива j
// второй аргумент - указатель на следующий элемент после конца j
print(begin(j), end(j)); // функции begin и end см. p. 3.5.3
Эта функция безопасна, пока вызывающая сторона правильно вычисляет указатели. Здесь эти указатели предоставляют библиотечные функции begin() и end() (см. раздел 3.5.3).
Явная передача параметра размера
Третий подход распространен в программах С и устаревших программах С++. Он подразумевает определение второго параметра, указывающего размер массива. Используя этот подход, перепишем функцию print() следующим образом:
// const int ia[] - эквивалент const int* ia
// размер передается явно и используется для контроля доступа
// к элементам ia
void print(const int ia[], size_t size) {
for (size_t i = 0; i != size; ++i) {
cout << ia[i] << endl;
}
}
Эта версия использует параметр size для определения количества выводимых элементов. Когда происходит вызов функции print(), ей следует передать этот дополнительный параметр:
int j[] = { 0, 1 }; // массив типа int размером 2
print(j, end(j) - begin(j));
Функция безопасна, пока переданный размер не превосходит реальную величину массива.
Параметры массива и константность
Обратите внимание, что все три версии функции print() определяли свои параметры массива как указатели на константу. В разделе 6.2.3 было упомянуто о схожести указателей и ссылок. Когда функция не нуждается в записи элементов массива, параметр массива должен быть указателем на константу (см. раздел 2.4.2). Параметр должен быть простым указателем на неконстантный тип, только если функция должна изменять значения элементов.
Ссылочный параметр массива
Подобно тому, как можно определить переменную, являющуюся ссылкой на массив (см. раздел 3.5.1), можно определить параметр, являющийся ссылкой на массив. Как обычно, ссылочный параметр привязан к соответствующему аргументу, которым в данном случае является массив:
// ok: параметр является ссылкой на массив; размерность - часть типа
void print(int (&arr)[10]) {
for (auto elem : arr)
cout << elem << endl;
}
Круглые скобки вокруг части &arr необходимы (см. раздел 3.5.1):
f(int &arr[10]) // ошибка: объявляет arr как массив ссылок
f(int (&arr)[10]) // ok: arr - ссылка на массив из десяти целых чисел
Поскольку размер массива является частью его типа, на размерность в теле функции вполне можно положиться. Однако тот факт, что размер является частью типа, ограничивает полноценность этой версии функции print(). Эту функцию можно вызвать только для массива из десяти целых чисел:
int i = 0, j[2]А также для временного отключения больших фрагментов кода при отладке. — Примеч. ред .
= {0, 1};
int k[10] = {0,1,2,3,4,5,6,7,8,9};
print(&i); // ошибка: аргумент не массив из десяти целых чисел
print(j); // ошибка: аргумент не массив из десяти целых чисел
print(k); // ok: аргумент массив из десяти целых чисел
В разделе 16.1.1 будет показано, как можно написать эту функцию способом, позволяющим передавать ссылочный параметр массива любого размера.
Передача многомерного массива
Напомним, что в языке С++ нет многомерных массивов (см. раздел 3.6). Вместо многомерных массивов есть массив массивов.
Подобно любому массиву, многомерный массив передается как указатель на его первый элемент (см. раздел 3.6). Поскольку речь идет о массиве массивов, элемент которого сам является массивом, указатель является указателем на массив. Размер второй размерности (и любой последующий) является частью типа элемента и должен быть определен:
// matrix указывает на первый элемент массива, элементы которого
// являются массивами из десяти целых чисел
void print(int (*matrix)[10], int rowSize) { /* ... */ }
Объявляет matrix указателем на массив из десяти целых чисел.
Круглые скобки вокруг *matrix снова необходимы:
int *matrix[10]; // массив из десяти указателей
int (*matrix)[10]; // указатель на массив из десяти целых чисел
Функцию можно также определить с использованием синтаксиса массива. Как обычно, компилятор игнорирует первую размерность, таким образом, лучше не включать ее:
// эквивалентное определение
void print (int matrix[][10], int rowSize) { /* ... */ }
Здесь объявление matrix выглядит как двумерный массив. Фактически параметр является указателем на массив из десяти целых чисел.
Упражнения раздела 6.2.4
Упражнение 6.21. Напишите функцию, получающую значение типа int и указатель на тип int, а возвращающую значение типа int, если оно больше, или значение, на которое указывает указатель, если больше оно. Какой тип следует использовать для указателя?
Упражнение 6.22. Напишите функцию, меняющую местами два указателя на тип int.
Упражнение 6.23. Напишите собственные версии каждой из функций print(), представленных в этом разделе. Вызовите каждую из этих функций для вывода i и j, определенных следующим образом:
int i = 0, j[2]А также для временного отключения больших фрагментов кода при отладке. — Примеч. ред .
= {0, 1};
Упражнение 6.24. Объясните поведение следующей функции. Если в коде есть проблемы, объясните, где они и как их исправить.
void print(const int ia[10]) {
for (size_t i = 0; i != 10; ++i)
cout << ia[i] << endl;
}
6.2.5. Функция
main()
: обработка параметров командной строки
Функция main() — хороший пример того, как программы на языке С++ передают массивы в функции. До сих пор функция main() в примерах определялась с пустым списком параметров.
int main() { ... }
Но зачастую функции main() необходимо передать аргументы. Обычно аргументы функции main() используют для того, чтобы позволить пользователю задать набор параметров, влияющих на работу программы. Предположим, например, что функция main() программы находится в исполняемом файле по имени prog. Параметры программе можно передавать следующим образом:
prog -d -о ofile data0
Так, параметры командной строки передаются функции main() в двух (необязательных) параметрах:
int main(int argc, char *argv[]) { ... }
Второй параметр, argv, является массивом указателей на символьные строки в стиле С, а первый параметр, argc, передает количество строк в этом массиве. Поскольку второй параметр является массивом, функцию main(), в качестве альтернативы, можно определить следующим образом:
int main(int argc, char **argv) { ... }
Обратите внимание: указатель argv указывает на тип char*. При передаче аргументов функции main() первый элемент массива argv содержит либо имя программы, либо является пустой строкой. Последующие элементы передают аргументы, предоставленные в командной строке. Элемент сразу за последним указателем гарантированно будет нулем.
С учетом предыдущей командной строки argc содержит значение 5, a argv — следующие символьные строки в стиле С:
argv[0] = "prog"; // может также указывать на пустую строку
argv[1]На самом деле препроцессору. Компилятор получит готовый промежуточный файл, в состав которого войдет содержимое подключенной библиотеки. — Примеч. ред .
= "-d";
argv[2]А также для временного отключения больших фрагментов кода при отладке. — Примеч. ред .
= "-o";
argv[3]Согласно другой трактовке, исключение — это объект системного или пользовательского класса, создаваемого операционной системой или кодом программы в ответ на обстоятельства, либо не допускающие дальнейшего нормального выполнения программы, либо определенные пользователем. Обработка исключений в приложении позволяет корректно выйти из затруднительной ситуации. — Примеч. ред .
= "ofile";
argv[4]Здесь и везде в оригинале именно adapt o r, а не adapt e r. — Примеч. ред .
= "data0";
argv[5] = 0;
При использовании аргументов в массиве argv помните, что дополнительные аргументы начинаются с элемента argv[1]На самом деле препроцессору. Компилятор получит готовый промежуточный файл, в состав которого войдет содержимое подключенной библиотеки. — Примеч. ред .
; элемент argv[0] содержит имя программы, а не введенный пользователем параметр.
Упражнения раздела 6.2.5
Упражнение 6.25. Напишите функцию main(), получающую два аргумента. Конкатенируйте предоставленные аргументы и выведите полученную строку.
Упражнение 6.26. Напишите программу, которая способна получать параметры командной строки, описанные в этом разделе. Отобразите значения аргументов, переданных функции main().
6.2.6. Функции с переменным количеством параметров
Иногда количество аргументов, подлежащих передаче функции, неизвестно заранее. Например, могла бы понадобиться функция, выводящая сообщения об ошибках, созданные нашей программой. Нам хотелось бы использовать одну функцию, чтобы выводить эти сообщения единообразным способом. Однако различные вызовы этой функции могли бы передавать разные аргументы, соответствующие разным видам сообщений об ошибках.
Новый стандарт предоставляет два основных способа создания функций, получающих переменное количество аргументов: если у всех аргументов тот же тип, можно передать объект библиотечного класса initializer_list. Если типы аргументов разные, можно написать функцию специального вида, известную как шаблон с переменным количеством аргументов (variadic template), который мы рассмотрим в разделе 16.4.
В языке С++ есть также специальный тип параметра, многоточие, применяющийся для передачи переменного количества аргументов. Мы кратко рассмотрим параметры в виде многоточия далее в этом разделе. Но следует заметить, что это средство обычно используют только в программах, которые должны взаимодействовать с функциями С.
Параметры типа initializer_list
Функцию, получающую произвольное количество аргументов одинакового типа, можно написать, используя параметр типа initializer_list. Тип initializer_list — это библиотечный класс, который представляет собой массив (см. раздел 3.5) значений определенного типа. Этот тип определен в заголовке initializer_list. Операции, предоставляемые классом initializer_list, перечислены в табл. 6.1.
Таблица 6.1. Операции, предоставляемые классом initializer_list
initializer_list<T> lst; | Инициализация по умолчанию; пустой список элементов типа T |
initializer_list<T> lst{a,b,с...}; | lst имеет столько элементов, сколько инициализаторов; элементы являются копиями соответствующих инициализаторов. Элементы списка — константы |
lst2(lst) lst2 = lst | Копирование или присвоение объекта класса. initializer_list не копирует элементы в списке. После копирования первоисточник и копия совместно используют элементы |
lst.size() | Количество элементов в списке |
lst.begin() lst.end() | Возвращает указатель на первый и следующий после последнего элементы lst |
Подобно типу vector, тип initializer_list является шаблоном (см. раздел 3.3). При определении объекта класса initializer_list следует указать тип элементов, которые будет содержать список:
initializer_list
initializer_list
В отличие от вектора, элементы списка initializer_list всегда константы; нет никакого способа изменить значение его элементов.
Функцию отображения сообщений об ошибках с переменным количеством аргументов можно написать следующим образом:
void error_msg(initializer_list
for (auto beg = il.begin(); beg != il.end(); ++beg)
cout << *beg << " ";
cout << endl;
}
Методы begin() и end() объектов класса initializer_list аналогичны таковым у класса vector (см. раздел 3.4.1). Метод begin() предоставляет указатель на первый элемент списка, а метод end() — на следующий элемент после последнего. Наша функция инициализирует переменную beg указателем на первый элемент и перебирает все элементы списка initializer_list. В теле цикла осуществляется обращение к значению beg, что позволяет получить доступ к текущему элементу и вывести его значение.
При передаче последовательности значений в параметре типа initializer_list последовательность следует заключить в фигурные скобки:
// expected и actual - строки
if (expected != actual)
error_msg({"functionX", expected, actual});
else
error_msg({"functionX", "okay"});
Здесь той же функции error_msg() передаются при первом вызове три значения, а при втором — два.
У функции с параметром initializer_list могут быть также и другие параметры. Например, у нашей системы отладки мог бы быть класс ErrCode, представляющий различные виды ошибок. Мы можем пересмотреть свою программу так, чтобы в дополнение к списку initializer_list передавать параметр типа ErrCode следующим образом:
void error_msg(ErrCode е, initializer_list
cout << e.msg() << ": ";
for (const auto &elem : il)
cout << elem << " ";
cout << endl;
}
Поскольку класс initializer_list имеет члены begin() и end(), мы можем использовать для обработки элементов серийный оператор for (см. раздел 5.4.3). Эта программа, как и предыдущая версия, перебирает элементы заключенного в фигурные скобки списка значений, переданных параметру il.
Для этой версии необходимо пересмотреть вызовы так, чтобы передать аргумент типа ErrCode:
if (expected != actual)
error_msg(ErrCode(42), {"functionX", expected, actual});
else
error_msg(ErrCode(0), {"functionX", "okay"});
#books.png Параметр в виде многоточия
Параметры в виде многоточия предоставляются языком С++ для взаимодействия программам с кодом на языке С, использующим такое средство библиотеки С, как varargs. В других целях параметр в виде многоточия не следует использовать. Использование varargs описано в документации компилятора С.
Параметры в виде многоточия должны использоваться только для таких типов, которые есть и у языка С, и у С++. В частности, большинство объектов типа класса копируются неправильно, когда передаются параметру в виде многоточия.
Параметр в виде многоточия может быть только последним элементом в списке параметров и может принять любую из двух форм:
void foo(parm_list, ...);
void foo(...);
Первая форма определяет тип (типы) для нескольких параметров функции foo(). Контроль типов аргументов, соответствующих определенным параметрам, осуществляется как обычно. Для аргументов, соответствующих параметру в виде многоточия, никакого контроля типов нет. В первой форме запятая после объявления параметра необязательна.
Упражнения раздела 6.2.6
Упражнение 6.27. Напишите функцию, получающую параметр типа initializer_list
Упражнение 6.28. Во второй версии функции error_msg(), где у нее есть параметр типа ErrCode, каков тип элемента в цикле for?
Упражнение 6.29. При использовании типа initializer_list в серийном операторе for использовали бы вы ссылку как управляющую переменную цикла? Объясните почему.
6.3. Типы возвращаемого значения и оператор
return
Оператор return завершает выполнение функции и возвращает управление той функции, которая вызвала текущую. Существуют две формы оператора return:
return;
return выражение ;
6.3.1. Функции без возвращаемого значения
Оператор return без значения применим только в такой функции, типом возвращаемого значения которой объявлен void. Функции, возвращаемым типом которых объявлен void, необязательно должны содержать оператор return. В функции типа void оператор return неявно размещается после последнего оператора.
Как правило, функции типа void используют оператор return для преждевременного завершения выполнения. Это аналогично использованию оператора break (см. раздел 5.5.1) для выход из цикла. Например, можно написать функцию swap(), которая не делает ничего, если значения идентичны:
void swap(int &v1, int &v2) {
// если значения равны, их замена не нужна; можно выйти сразу
if (v1 == v2)
return;
// если мы здесь, придется поработать
int tmp = v2;
v2 = v1;
v1 = tmp;
// явно указывать оператор return не обязательно
}
Сначала эта функция проверяет, не равны ли значения, и если это так, то завершает работу. Если значения не равны, функция меняет их местами. После последнего оператора присвоения осуществляется неявный выход из функции.
Функции, для возвращаемого значения которых указан тип void, вторую форму оператора return могут использовать только для возвращения результата вызова другой функции, которая возвращает тип void. Возвращение любого другого выражения из функции типа void приведет к ошибке при компиляции.
6.3.2. Функции, возвращающие значение
Вторая форма оператора return предназначена для возвращения результата из функции. Каждый случай возвращения значения типа, отличного от void, должен возвратить значение. Возвращаемое значение должно иметь тип, либо совпадающий, либо допускающий неявное преобразование (см. раздел 4.11) в тип, указанный для возвращаемого значения функции при определении.
Хотя язык С++ не может гарантировать правильность результата, он способен гарантировать, что каждое возвращаемое функцией значение будет соответствовать объявленному типу. Это может получиться не во всех случаях, компилятор попытается обеспечить возвращение значения и выход только через допустимый оператор return. Например:
// некорректное возвращение значения, этот код не будет откомпилирован
bool str_subrange(const string &str1, const string &str2) {
// размеры одинаковы: возвратить обычный результат сравнения
if (str1.size() == str2.size())
return str1 == str2; // ok: == возвращает bool
// найти размер меньшей строки; условный оператор см. раздел 4.7
auto size = (str1.size() < str2.size())
? str1.size() : str2.size();
// просмотреть все элементы до размера меньшей строки
for (decltype(size) i = 0; i != size; ++i) {
if (str1[i] != str2[i])
return; // ошибка #1: нет возвращаемого значения; компилятор
// должен обнаружить эту ошибку
}
// ошибка #2: выполнение может дойти до конца функции, так и
// не встретив оператор return
// компилятор может и не обнаружить эту ошибку
}
Оператор return в цикле for является ошибочным потому, что он не в состоянии вернуть значение. Эту ошибку компилятор должен обнаружить.
Вторая ошибка заключается в том, что функция не имеет оператора return после цикла. Если произойдет вызов этой функции со строкой, являющейся подмножеством другой, процесс выполнения минует цикл for. Однако оператор return для этого случая не предусмотрен. Эту ошибку компилятор может и не обнаружить. В этом случае поведение программы во время выполнения будет непредсказуемо.
Отсутствие оператора return после цикла, который этот оператор содержит, является особенно коварной ошибкой. Однако большинство компиляторов ее не обнаружит.
Как возвращаются значения
Значения функций возвращаются тем же способом, каким инициализируются переменные и параметры: возвращаемое значение используется для инициализации временного объекта в точке вызова, и этот временный объект является результатом вызова функции.
В функциях, возвращающих локальные переменные, важно иметь в виду правила инициализации. Например, можно написать функцию, которой передают счетчик, слово и окончание. Функция возвращает множественную форму слова, если счетчик больше 1:
// возвратить множественную форму слова, если ctr больше 1
string make_plural(size_t ctr, const string &word,
const string &ending) {
return (ctr > 1) ? word + ending : word;
}
Тип возвращаемого значения этой функции — string, это значит, что возвращаемое значение копируется в точке вызова. Функция возвращает копию значения word или безымянную временную строку, полученную конкатенацией word и ending.
Когда функция возвращает ссылку, она, подобно любой другой ссылке, является только другим именем для объекта, на который ссылается. Рассмотрим, например, функцию, возвращающую ссылку на более короткую из двух переданный ей строк:
// возвратить ссылку на строку, которая короче
const string &shorterString(const string &s1, const string &s2) {
return s1.size() <= s2.size() ? s1 : s2;
}
Параметры и возвращаемое значение имеют тип ссылки на const string. Строки не копируются ни при вызове функции, ни при возвращении результата.
Никогда не возвращайте ссылку на локальный объект
По завершении работы функции все хранилища ее локальных объектов освобождаются (см. раздел 6.1.1). Поэтому после завершения работы функции ссылки на ее локальные объекты ссылаются на несуществующие объекты.
// катастрофа: функция возвращает ссылку на локальный объект
const string &manip() {
string ret;
// обработать ret некоторым образом
if (!ret.empty())
return ret; // ошибка: возвращение ссылки на локальный объект!
else
return "Empty"; // ошибка: "Empty" - локальная временная строка
}
Эта функция приведет к отказу во время выполнения, поскольку она возвращает ссылку на локальный объект. Когда функция завершит работу, область памяти, которую занимала переменная ret, будет освобождена. Возвращаемое значение будет ссылаться на ту область памяти, которая уже недоступна.
Оба оператора return возвращают здесь неопределенное значение — неизвестно, что будет, попробуй мы использовать значение, возвращенное функцией manip(). В первом операторе return очевидно, что функция пытается вернуть ссылку на локальный объект. Во втором случае строковый литерал преобразуется в локальный временный объект класса string. Этот объект, как и строка s, является локальным объектом функции manip(). Область памяти, в которой располагается временный объект, освобождается по завершении функции. Оба оператора return возвращают ссылки на области памяти, которые больше недоступны.
Чтобы удостовериться в безопасности возвращения значения, следует задаться вопросом: к какому существовавшему ранее объекту относится ссылка?
Возвращать указатель на локальный объект нельзя по тем же причинам. По завершении функции локальные объекты освобождаются, и указатель указывает на несуществующий объект.
Функции, возвращающие типы класса и оператор вызова
Подобно любому оператору, оператор вызова обладает порядком (ассоциативностью) и приоритетом (см. раздел 4.1.2). У оператора вызова тот же приоритет, что и у операторов "точка" и "стрелка" (см. раздел 4.6). Как и эти операторы, оператор вызова имеет левосторонний порядок. В результате, если функция возвращает указатель, ссылку или объект типа класса, результат вызова можно использовать для обращения к члену полученного объекта.
Например, размер более короткой строки можно определить следующим образом:
// обращение к методу size объекта строки, возвращенной shorterString
auto sz = shorterString(s1, s2).size();
Поскольку эти операторы имеют левосторонний порядок, результат вызова функции shorterString() будет левым операндом точечного оператора. Этот оператор выбирает метод size() объекта строки. Этот метод становится левым операндом второго оператора вызова.
Возвращаемая ссылка является l-значением
Является ли вызов функции l-значением (см. раздел 4.1.1), зависит от типа возвращаемого значения функции. Вызовы функции, возвращающей ссылку, являются l-значением; другие типы возвращаемого значения являются r-значениями. Вызов функции, возвращающей ссылку, применяется таким же способом, как и любое другое l-значение. В частности, можно осуществлять присвоение результату вызова функции, возвращающей ссылку на неконстанту:
char &get_val(string &str, string::size_type ix) {
return str[ix]; // get_val подразумевает, что данный индекс допустим
}
int main() {
string s("a value");
cout << s << endl; // отображает значение
get_val(s, 0) = 'A'; // изменяет s[0] на A
cout << s << endl; // отображает значение A
return 0;
}
Может быть несколько странно видеть вызов функции слева от оператора присвоения. Однако в этом нет ничего необычного. Возвращаемое значение — ссылка, поэтому вызов — это l-значение, а любое l-значение может быть левым операндом оператора присвоения.
Если тип возвращаемого значения является ссылкой на константу, то (как обычно) присвоение результату вызова невозможно:
shorterString("hi", "bye") = "X"; // ошибка: возвращаемое значение
// является константой
Списочная инициализация возвращаемого значения
По новому стандарту функции могут возвращать окруженный скобками список значений. Подобно любому другому случаю возвращения значения, список используется для инициализации временного объекта, представляющего возвращение функцией значения. Если список пуст, временный объект инициализируется значением по умолчанию (см. раздел 3.3.1). В противном случае возвращаемое значение зависит от типа возвращаемого значения функции.
Для примера вернемся к функции error_msg из раздел 6.2.6. Эта функция получала переменное количество строковых аргументов и выводило сообщение об ошибке, составленное из переданных строк. Теперь вместо вызова функции error_msg() мы возвратим вектор, содержащий строки сообщений об ошибке:
vector
// ...
// expected и actual - строки
if (expected.empty())
return {}; // возвратить пустой вектор
else if (expected == actual)
return {"functionX", "okay"}; // возвратить вектор
// инициализированный списком
else
return {"functionX", expected, actual};
}
В первом операторе return возвращается пустой список. В данном случае возвращенный обработанный вектор будет пуст. В противном случае возвращается вектор, инициализированный двумя или тремя элементами, в зависимости от того, равны ли expected и actual.
У функции, возвращающей встроенный тип, заключенный в скобки список может содержать хотя бы одно значение, и это значение не должно требовать сужающего преобразования (см. раздел 2.2.1). Если функция возвращает тип класса, то используемые инициализаторы определяет сам класс (см. раздел 3.3.1).
Возвращение значения из функции main()
Есть одно исключение из правила, согласно которому функция с типом возвращаемого значения, отличного от void, обязана возвратить значение: функция main() может завершить работу без возвращения значения. Если процесс выполнения достигает конца функции main() и нет никакого значения для возвращения, компилятор неявно добавляет возвращение значения 0.
Как упоминалось в разделе 1.1, значение, возвращаемое из функции main(), рассматривается как индикатор состояния. Возвращение нулевого значения означает успех; большинство других значений — неудачу. У значения, отличного от нуля, есть машинно-зависимое значение. Чтобы сделать его независимым от машины, заголовок cstdlib определяет две переменные препроцессора (см. раздел 2.3.2), которые можно использовать для индикации успеха или отказа:
int main() {
if (some failure)
return EXIT_FAILURE; // определено в cstdlib
else
return EXIT_SUCCESS; // определено в cstdlib
}
Поскольку это переменные препроцессора, им не должна предшествовать часть std:: и их нельзя использовать в объявлениях using.
Рекурсия
Функция, которая вызывает себя прямо или косвенно, является рекурсивной функцией (recursive function). В качестве примера можно переписать функцию вычисления факториала так, чтобы использовать рекурсию:
// вычислить val!, т.е. 1 * 2 * 3 ... * val
int factorial(int val) {
if (val > 1)
return factorial(val-1) * val;
return 1;
}
В этой реализации осуществляется рекурсивный вызов функции factorial(), чтобы вычислить факториал числа, начиная со значения, первоначально переданного val, и далее в обратном порядке. Когда значение val достигнет 1, рекурсия останавливается и возвращается значение 1.
В рекурсивной функции всегда должно быть определено условие выхода или останова (stopping condition); в противном случае рекурсия станет бесконечной, т.е. функция продолжит вызывать себя до тех пор, пока стек программы не будет исчерпан. Иногда эта ошибка называется бесконечной рекурсией (infinite recursion). В случае функции factorial() условием выхода является равенство значения параметра val единице.
Ниже приведена трассировка выполнения функции factorial() при передаче ей значения 5.
Трассировка вызова функции factorial(5)
Вызов | Возвращает | Значение |
factorial(5) | factorial(4) * 5 | 120 |
factorial(4) | factorial(3) * 4 | 24 |
factorial(3) | factorial(2) * 3 | 6 |
factorial(2) | factorial(1) * 2 | 2 |
factorial(1) | 1 | 1 |
Функция main() не может вызывать сама себя.
Упражнения раздела 6.3.2
Упражнение 6.30. Откомпилируйте версию функции str_subrange(), представленной в начале раздела, и посмотрите, что ваш компилятор делает с указанными сообщениями об ошибках.
Упражнение 6.31. Когда допустимо возвращение ссылки? Когда ссылки на константу?
Упражнение 6.32. Укажите, корректна ли следующая функция. Если да, то объясните, что она делает; в противном случае исправьте ошибки, а затем объясните все.
int &get(int *arry, int index) { return arry[index]; }
int main() {
int ia[10];
for (int i = 0; i != 10; ++i)
get(ia, i) = i;
}
Упражнение 6.33. Напишите рекурсивную функцию, выводящую содержимое вектора.
Упражнение 6.34. Что случится, если условие остановки функции factorial() будет таким:
if (val != 0)
Упражнение 6.35. Почему в вызове функции factorial() мы передали val-1, а не val--?
6.3.3. Возвращение указателя на массив
Поскольку копировать массив нельзя, функция не может возвратить его. Но функция может возвратить указатель или ссылку на массив (см. раздел 3.5.1). К сожалению, синтаксис, обычно используемый для определения функций, которые возвращают указатели или ссылки на массив, довольно сложен. К счастью, такие объявления можно упростить. Например, можно использовать псевдоним типа (см. раздел 2.5.1):
typedef int arrT[10]; // arrT синоним для типа массива из десяти
// целых чисел
using arrtT = int[10]; // эквивалентное объявление arrT;
// см. раздел 2.5.1
arrT* func(int i); // func возвращает указатель на массив из
// пяти целых чисел
где arrT — это синоним для массива из десяти целых чисел. Поскольку нельзя возвратить массив, мы определяем тип возвращаемого значения как указатель на этот тип. Таким образом, функция func() получает один аргумент типа int и возвращает указатель на массив из десяти целых чисел.
Объявление функции, возвращающей указатель на массив
Чтобы объявить функцию func(), не используя псевдоним типа, следует вспомнить, что размерность массива следует за определяемым именем:
int arr[10]; // arr массив из десяти целых чисел
int *p1[10]; // p1 массив из десяти указателей
int (*p2)[10] = &arr; // p2 указывает на массив из десяти целых чисел
Подобно этим объявлениям, если необходимо определить функцию, которая возвращает указатель на массив, размерность должна следовать за именем функции. Однако функция имеет список параметров, который также следует за именем. Список параметров предшествует размерности. Следовательно, функция, которая возвращает указатель на массив, имеет такую форму:
Тип (* функция ( список_параметров ))[ размерность ]
Как и в любом другом объявлении массива, Тип — это тип элементов, а размерность — это размер массива. Круглые скобки вокруг части (* функция ( список_параметров )) необходимы по той же причине, по которой они были нужны при определили указателя p2. Без них мы определили бы функцию, которая возвращает массив указателей.
В качестве конкретного примера рассмотрим следующее объявление функции func(), не использующей псевдоним типа:
int (*func(int i))[10];
Чтобы понять это объявление, имеет смысл прочитать его следующим образом:
• func(int) указывает, что функцию func() можно вызвать с аргументом типа int;
• (*func(int)) указывает, что можно обратиться к значению результата этого вызова;
• (*func(int))[10] указывает, что обращение к значению результата вызова функции func() возвращает массив из десяти элементов;
• int (*func(int))[10] указывает, что типом элементов этого массива является int.
Использование замыкающего типа возвращаемого значения
По новому стандарту есть и другой способ упростить объявления функции func() — с использованием замыкающего типа возвращаемого значения (trailing return type). Оно может быть определено для любой функции, но полезней всего оно для функций со сложными типами возвращаемого значения, такими как указатели (или ссылки) на массивы. Замыкающий тип возвращаемого значения следует за списком параметров и предваряется символом ->. Чтобы сообщить о том, что возвращаемое значение следует за списком параметров, ключевое слово auto располагается там, где обычно присутствует тип возвращаемого значения:
// fcn получает аргумент типа int и возвращает указатель на массив
// из десяти целых чисел
auto func(int i) -> int(*)[10];
Поскольку тип возвращаемого значения указан после списка параметров, проще заметить, что функция func() возвращает указатель и что этот указатель указывает на массив из десяти целых чисел.
Использование спецификатора decltype
В качестве другой альтернативы, если известен массив (массивы), указатель на который способна возвратить наша функция, можно использовать спецификатор decltype, чтобы объявить тип возвращаемого значения. Например, следующая функция возвращает указатель на один из двух массивов, в зависимости от значения ее параметра:
int odd[] = {1,3,5,7,9};
int even[] = {0,2,4,6,8};
// возвращает указатель на массив из пяти элементов типа int
decltype(odd) *arrPtr(int i) {
return (i % 2) ? &odd : &even; // возвращает указатель на массив
}
Тип возвращаемого значения функции arrPtr() указан как decltype, свидетельствуя о том, что функция возвращает указатель на любой тип, который имеет odd. В данном случае этот объект является массивом, поэтому функция arrPtr() возвращает указатель на массив из пяти целых чисел.
Единственная сложность здесь в том, что следует помнить, что спецификатор decltype не преобразовывает автоматически массив в указатель соответствующего ему типа. Тип, возвращенный спецификатором decltype, является типом массива, для которого нужно добавить *, чтобы указать, что функция arrPtr() возвращает указатель.
Упражнения раздела 6.3.3
Упражнение 6.36. Напишите объявление функции, возвращающей ссылку на массив из десяти строк, не используя ни замыкающий тип возвращаемого значения, ни спецификатор decltype или псевдоним типа.
Упражнение 6.37. Напишите три дополнительных объявления для функций предыдущего упражнения. Нужно использовать псевдоним типа, замыкающий тип возвращаемого значения и спецификатор decltype. Какую форму вы предпочитаете и почему?
Упражнение 6.38. Перепишите функцию arrPtr() так, чтобы она возвращала ссылку на массив.
6.4. Перегруженные функции
Функции, расположенные в одной области видимости, называются перегруженными (overloaded), если они имеют одинаковые имена, но разные списки параметров. Пример определения нескольких функций по имени print() приведен в разделе 6.2.4:
void print(const char *cp);
void print(const int *beg, const int *end);
void print(const int ia[], size_t size);
Эти функции выполняют одинаковое действие, но их параметры относятся к разным типам. При вызове такой функции компилятор принимает решение о применении конкретной версии на основании типа переданного аргумента:
int j[2]А также для временного отключения больших фрагментов кода при отладке. — Примеч. ред .
= {0, 1};
print("Hello World"); // вызов print (const char*)
print(j, end(j) - begin(j)); // вызов print(const int*, size_t)
print(begin(j), end(j)); // вызов print(const int*, const int*)
Перегрузка функций избавляет от необходимости придумывать (и помнить) имена, существующие только для того, чтобы помочь компилятору выяснить, которую из функций применять при вызове.
Функция main() не может быть перегружена.
Определение перегруженных функций
Рассмотрим приложение базы данных с несколькими функциями для поиска записи на основании имени, номера телефона, номер счета и т.д. Перегрузка функций позволит определить коллекцию функций, каждая по имени lookup(), которые отличаются тем, как они осуществляют поиск. Мы сможем вызвать функцию lookup(), передав значение любого из следующих типов:
Record lookup(const Account&); // поиск по счету
Record lookup(const Phone&); // поиск по телефону
Record lookup(const Name&); // поиск по имени
Account acct;
Phone phone;
Record r1 = lookup(acct); // вызов версии, получающей Account
Record r2 = lookup(phone); // вызов версии, получающей Phone
Здесь у всех трех функций одинаковое имя, но все же это три разные функции. Чтобы выяснить, которую из них вызвать, компилятор использует тип (типы) аргументов.
Перегруженные функции должны отличаться по количеству или типу (типам) своих параметров. Каждая из функций выше получает один параметр, но типы у этих параметров разные.
Функции не могут отличаться только типами возвращаемого значения. Если списки параметров функций совпадают, а типы возвращаемого значения отличаются, то это будет ошибкой:
Record lookup(const Account&);
bool lookup(const Account&); // ошибка: отличается только типом
// возвращаемого значения
Различие типов параметров
Два списка параметров могут быть идентичными, даже если они не выглядят одинаково:
// каждая пара объявляет ту же функцию
Record lookup(const Account &acct);
Record lookup(const Account&); // имена параметров игнорируются
typedef Phone Telno;
Record lookup(const Phone&);
Record lookup(const Telno&); // Telno и Phone того же типа
Первое объявление в первой паре именует свой параметр. Имена параметров предназначены только для документирования. Они не изменяют список параметров.
Во второй паре типы только выглядят разными, Telno — не новый тип, это только синоним типа Phone. Псевдоним типа (см. раздел 2.5.1) предоставляет альтернативное имя для уже существующего типа, а не создает новый тип. Поэтому два параметра, отличающиеся только тем, что один использует имя типа, а другой его псевдоним, не являются разными.
#magnify.png Перегрузка и константные параметры
Как упоминалось в разделе 6.2.3, спецификатор const верхнего уровня (см. раздел 2.4.3) никак не влияет на объекты, которые могут быть переданы функции. Параметр, у которого есть спецификатор const верхнего уровня, неотличим от такового без спецификатора const верхнего уровня:
Record lookup(Phone);
Record lookup(const Phone); // повторно объявляет Record lookup(Phone)
Record lookup(Phone*);
Record lookup(Phone* const); // повторно объявляет
// Record lookup(Phone*)
Здесь вторые объявления повторно объявляет ту же функцию, что и первые. С другой стороны, функцию можно перегрузить на основании того, является ли параметр ссылкой (или указателем) на константную или неконстантную версию того же типа; речь идет о спецификаторе const нижнего уровня:
// функции, получающие константную и неконстантную ссылку (или
// указатель), имеют разные параметры
Record lookup(Account&); // функция получает ссылку на Account
Record lookup(const Account&); // новая функция получает константную
// ссылку
Record lookup(Account*); // новая функция получает указатель
// на Account
Record lookup(const Account*); // новая функция получает указатель на
// константу
В этих случаях компилятор может использовать константность аргумента, чтобы различить, какую функцию применять. Поскольку нет преобразования (см. раздел 4.11.2) из константы, можно передать константный объект (или указатель на константу) только версии с константным параметром. Так как преобразование в константу возможно, можно вызвать функцию и неконстантного объекта, и указателя на неконстантный объект. Однако, как будет представлено в разделе 6.6.1, компилятор предпочтет неконстантные версии при передаче неконстантного объекта или указателя на неконстантный объект.
Совет. Когда не следует перегружать функции
Хотя перегрузка функций позволяет избежать необходимости создавать и запоминать имена общепринятых операций, она не всегда целесообразна. В некоторых случаях разные имена функций предоставляют дополнительную информацию, которая упрощает понимание программы. Давайте рассмотрим набор функций-членов класса Screen , отвечающих за перемещение курсора.
Screen& moveHome();
Screen& moveAbs(int, int);
Screen& moveRel(int, int, string direction);
На первый взгляд может показаться, что этот набор функций имеет смысл перегрузить под именем move :
Screen& move();
Screen& move(int, int);
Screen& move(int, int, string direction);
Однако при перегрузке этих функций мы потеряли информацию, которая была унаследована именами функции. Хотя перемещение курсора — это общая операция, совместно используемая всеми этими функциями, специфический характер перемещения уникален для каждой из этих функций. Рассмотрим, например, функцию moveHome() , осуществляющую вполне определенное перемещение курсора. Какое из двух приведенных ниже обращений понятнее при чтении кода?
// которая из записей понятней?
myScreen.moveHome(); // вероятно, эта!
myScreen.move();
Оператор const_cast и перегрузка
В разделе 4.11.3 упоминалось, что оператор const_cast особенно полезен в контексте перегруженных функций. В качестве примера вернемся к функции shorterString() из раздела 6.3.2:
// возвратить ссылку на строку, которая короче
const string &shorterString (const string &s1, const string &s2) {
return s1.size() <= s2.size() ? s1 : s2;
}
Эта функция получает и возвращает ссылки на константную строку. Мы можем вызвать функцию с двумя неконстантными строковыми аргументами, но как результат получим ссылку на константную строку. Могла бы понадобиться версия функции shorterString(), которая, получив неконстантные аргументы, возвратит обычную ссылку. Мы можем написать эту версию функции, используя оператор const_cast:
string &shorterString(string &s1, string &s2) {
auto &r = shorterString(const_cast
const_cast
return const_cast
}
Эта версия вызывает константную версию функции shorterString() при приведении типов ее аргументов к ссылкам на константу. Функция возвращает ссылку на тип const string, которая, как известно, привязана к одному из исходных, неконстантных аргументов. Следовательно, приведение этой строки назад к обычной ссылке string& при возвращении вполне безопасно.
Вызов перегруженной функции
Когда набор перегруженных функций определен, необходима возможность вызвать их с соответствующими аргументами. Подбор функции (function matching), известный также как поиск перегруженной функции (overload resolution), — это процесс, в ходе которого вызов функции ассоциируется с определенной версией из набора перегруженных функций. Компилятор определяет, какую именно версию функции использовать при вызове, сравнивая аргументы вызова с параметрами каждой функции в наборе.
Как правило, вовсе несложно выяснить, допустим ли вызов, и если он допустим, то какая из версий функции будет использована компилятором. Функции в наборе перегруженных версий отличаются количеством или типом аргументов. В таких случаях определить используемую функцию просто. Подбор функции усложняется в случае, когда количество параметров одинаково и они допускают преобразование (см. раздел 4.11) переданных аргументов. Распознавание вызовов компилятором при наличии преобразований рассматривается в разделе 6.6, а пока следует понять, что при любом вызове перегруженной функции возможен один из трех результатов.
• Компилятор находит одну функцию, которая является наилучшим соответствием (best match) для фактических аргументов, и создает код ее вызова.
• Компилятор не может найти ни одной функции, параметры которой соответствуют аргументам вызова. В этом случае компилятор сообщает об ошибке отсутствия соответствия (no match).
• Компилятор находит несколько функций, которые в принципе подходят, но ни одна из них не соответствует полностью. В этом случае компилятор также сообщает об ошибке, об ошибке неоднозначности вызова (ambiguous call).
Упражнения раздела 6.4
Упражнение 6.39. Объясните результат второго объявления в каждом из следующих наборов. Укажите, какое из них (если есть) недопустимо.
(a) int calc(int, int);
int calc(const int, const int);
(b) int get();
double get();
(c) int *reset(int *);
double *reset(double *);
6.4.1. Перегрузка и область видимости
Обычно объявлять функцию локально нежелательно. Но чтобы объяснить, как область видимости взаимодействует с перегрузкой, мы будем нарушать это правило и используем локальные объявление функции.
Новички в программировании на языке С++ зачастую не понимают взаимодействия между областью видимости и перегрузкой. Однако у перегрузки нет никаких специальных свойств относительно области видимости. Как обычно, если имя объявлено во внутренней области видимости, оно скрывает (hidden name) такое же имя, объявленное во внешней области видимости. Имена не перегружают в областях видимости:
string read();
void print(const string &);
void print(double); // перегружает функцию print
void fooBar(int ival) {
bool read = false; // новая область видимости: скрывает
// предыдущее объявление имени read
string s = read(); // ошибка: read - переменная типа bool, а не
// функция
// плохой подход: обычно не следует объявлять функции в локальной
// области видимости
void print(int); // новая область видимости: скрывает предыдущие
// экземпляры функции print
print("Value: "); // ошибка: print(const string &) скрыта
print(ival); // ok: print (int) видима
print(3.14); // ok: вызов print(int); print(double) скрыта
}
Большинство читателей не удивит ошибка при вызове функции read(). Когда компилятор обрабатывает вызов функции read(), он находит локальное определение имени read. Это имя принадлежит переменной типа bool, а не функции. Следовательно, вызов некорректен.
Точно тот же процесс используется при распознавании вызова функции print(). Объявление print(int) в функции fooBar скрывает прежнее ее объявление. В результате будет доступна только одна функция print(), та, которая получает один параметр типа int.
Когда происходит вызов функции print(), компилятор ищет сначала объявление этого имени. Он находит локальное объявление функции print(), получающей один параметр типа int. Как только имя найдено, компилятор игнорирует такое же имя в любой внешней области видимости. Он полагает данное объявление единственно доступным для использования. Остается лишь удостовериться в допустимости использования этого имени.
В языке С++ поиск имени осуществляется до проверки соответствия типов.
Первый вызов передает функции print() строковый литерал, но единственное ее объявление, находящееся в области видимости, имеет параметр типа int. Строковый литерал не может быть преобразован в тип int, поэтому вызов ошибочен. Функция print(const string&), которая соответствовала бы этому вызову, скрыта и не рассматривается.
Когда происходит вызов функции print() с передачей аргумента типа double(), процесс повторяется. Компилятор находит локальное определение функции print(int). Но аргумент типа double может быть преобразован в значение типа int, поэтому вызов корректен.
Если бы объявление print(int) находилось в той же области видимости, что и объявления других версий функции print(), это была бы еще одна ее перегруженная версия. В этом случае вызовы распознавались бы по-другому, поскольку компилятор видел бы все три функции:
void print(const string &);
void print(double); // перегружает функцию print
void print(int); // еще один экземпляр перегрузки
void fooBar2(int ival) {
print("Value: "); // вызов print(const string &)
print(ival); // вызов print(int)
print(3.14); // вызов print(double)
}
6.5. Специальные средства
В этом разделе рассматриваются три связанных с функциями средства, которые полезны во многих, но не во всех программах: аргументы по умолчанию, встраиваемые функции и функции constexpr, а также некоторые другие средства, обычно используемые во время отладки.
6.5.1. Аргументы по умолчанию
Параметры некоторых функций могут обладать конкретными значениями, используемыми в большинстве, но не во всех вызовах. Такие обычно используемые значения называют аргументом по умолчанию (default argument). Функции с аргументами по умолчанию могут быть вызваны с ними или без них.
Например, для представления содержимого окна можно было бы использовать тип string. Мы могли бы хотеть, чтобы по умолчанию у окна была определенная высота, ширина и фоновый символ. Но мы могли бы также захотеть позволить пользователям использовать собственные значения, кроме значений по умолчанию. Чтобы приспособить и значение по умолчанию, и определяемое пользователем, мы объявили бы функцию, представляющую окно, следующим образом:
typedef string::size_type sz; // typedef см. p. 2.5.1
string screen(sz ht = 24, sz wid = 80, char backgrnd = ' ');
Здесь мы предоставили для каждого параметра значение по умолчанию. Аргумент по умолчанию определяется как инициализатор параметра в списке параметров. Значения по умолчанию можно определить как для одного, так и для нескольких параметров. Но если у параметра есть аргумент по умолчанию, то все параметры, следующие за ним, также должны иметь аргументы по умолчанию.
Вызов функции с аргументами по умолчанию
Если необходимо использовать аргумент по умолчанию, его значение при вызове функции пропускают. Поскольку функция screen() предоставляет значения по умолчанию для всех параметров, мы можем вызвать ее без аргументов, с одним, двумя или тремя аргументами:
string window;
window = screen(); // эквивалент screen(24, 80, ' ')
window = screen(66); // эквивалент screen(66, 80, ' ')
window = screen(66, 256); // screen(66, 256, ' ')
window = screen(66, 256, '#'); // screen(66, 256, '#')
Аргументы в вызове распознаются по позиции. Значения по умолчанию используются для аргументов, крайних справа. Например, чтобы переопределить значение по умолчанию параметра background, следует поставить также аргументы для параметров height и width:
window = screen(, , '?'); // ошибка: можно пропустить аргументы только
// крайние справа
window = screen('?'); // вызов screen('?', 80, ' ')
Обратите внимание, что второй вызов, передающий одно символьное значение, вполне допустим. Несмотря на допустимость, это вряд ли то, что ожидалось. Вызов допустим потому, что символ '?' имеет тип char, а он может быть преобразован в тип крайнего левого параметра. Это параметр типа string::size_type, который является целочисленным беззнаковым типом. В этом вызове аргумент типа char неявно преобразуется в тип string::size_type и передается как аргумент параметру height. На машине авторов символ '?' имеет шестнадцатеричное значение 0x3F, соответствующее десятичному 63. Таким образом, этот вызов присваивает параметру height значение 63.
Одной из задач при разработке функции с аргументами по умолчанию является упорядочивание параметров так, чтобы те из них, для которых использование значения по умолчанию вероятней всего, располагались последними.
Объявление аргумента по умолчанию
Хотя вполне обычной практикой является объявление функции однажды в заголовке, вполне допустимо многократно объявлять ее повторно. Однако у каждого параметра может быть свое значение по умолчанию, определенное только однажды в данной области видимости. Таким образом, любое последующее объявление может добавить значение по умолчанию только для того параметра, у которого ранее не было определено значение по умолчанию. Как обычно, значения по умолчанию могут быть определены, только если у всех параметров справа уже есть значения по умолчанию. Рассмотрим следующий пример:
// у параметров height и width нет значений по умолчанию
string screen(sz, sz, char = ' ');
Нельзя изменить уже заявленное значение по умолчанию:
string screen(sz, sz, char = '*'); // ошибка: переобъявление
Но можно добавить аргумент по умолчанию следующим образом:
string screen(sz = 24, sz = 80, char); // ok: добавление аргументов
// по умолчанию
Обычно аргументы по умолчанию определяют при объявлении функции в соответствующем заголовке.
Инициализация аргумента по умолчанию
Локальные переменные не могут использоваться как аргумент по умолчанию. За исключением этого ограничения, аргумент по умолчанию может быть любым выражением, тип которого приводим к типу параметра:
// объявления wd, def и ht должны располагаться вне функции
sz wd = 80;
char def = ' ';
sz ht();
string screen(sz = ht(), sz = wd, char = def);
string window = screen(); // вызов screen(ht(), 80, ' ')
Поиск имен, используемых для аргументов по умолчанию, осуществляется в пределах объявления функции. Значения, представляемые этими именами, вычисляются во время вызова:
void f2() {
def = '*'; // изменение значения аргумента по умолчанию
sz wd = 100; // скрывает внешнее определение wd, но не изменяет
// значение по умолчанию
window = screen(); // вызов screen(ht(), 80, '*')
}
В функции f2() было изменено значение def. Вызов функции screen передает это измененное значение. Эта функция также объявляет локальную переменную, которая скрывает внешнюю переменную wd. Однако локальное имя wd никак не связано с аргументом по умолчанию, переданным функции screen().
Упражнения раздела 6.5.1
Упражнение 6.40. Какое из следующих объявлений (если оно есть) содержит ошибку? Почему?
(a) int ff(int a, int b = 0, int с = 0);
(b) char *init(int ht = 24, int wd, char bckgrnd);
Упражнение 6.41. Какие из следующих вызовов (если они есть) недопустимы? Почему? Какие из них допустимы (если они есть), но, вероятно, не соответствуют намерениям разработчика? Почему?
char *init(int ht, int wd = 80, char bckgrnd = ' ');
(a) init(); (b) init(24,10); (c) init(14, '*');
Упражнение 6.42. Присвойте второму параметру функции make_plural() (см. раздел 6.3.2) аргумент по умолчанию 's'. Проверьте программу, выведя слова "success" и "failure" в единственном и множественном числе.
6.5.2. Встраиваемые функции и функции
constexpr
В разделе 6.3.2 приведена небольшая функция, возвращающая ссылку на более короткую строку из двух переданных ей. К преимуществам определения функции для такой маленькой операции относятся следующие.
• Обращение к функции shorterString() проще и понятнее, чем эквивалентное условное выражение.
• Использование функции гарантирует одинаковое поведение. Она гарантирует, что каждая проверка будет выполнена тем же способом.
• Если придется внести изменение, проще сделать это в теле функции, а не выискивать в коде программы все случаи применения эквивалентного выражения.
• Функция может быть многократно использована при написании других приложений.
Однако у функции shorterString() есть один потенциальный недостаток: ее вызов происходит медленнее, чем вычисление эквивалентного выражения. На большинстве машин при вызове функции осуществляется довольно много действий: перед обращением сохраняются регистры, которые необходимо будет восстановить после выхода; происходит копирование значений аргументов; управление программой переходит к новому участку кода.
Встраиваемые функции позволяют избежать дополнительных затрат на вызов
Содержимое функции, объявленной встраиваемой (inline) при компиляции, как правило, встраивается по месту вызова. Предположим, что функция shorterString() объявлена встраиваемой, а ее вызов имеет такой вид:
cout << shorterString(s1, s2) << endl;
При компиляции тело функции окажется встроено по месту вызова, и в результате получится нечто вроде следующего:
cout << (s1.size() < s2.size() ? s1 : s2) << endl;
Таким образом, во время выполнения удастся избежать дополнительных затрат, связанных с вызовом функции shorterString().
Чтобы объявить функцию shorterString() встраиваемой, в определении, перед типом возвращаемого значения, располагают ключевое слово inline.
// встраиваемая версия функции сравнения двух строк
inline const string &
shorterString(const string &s1, const string &s2) {
return s1.size() <= s2.size() ? s1 : s2;
}
Объявление функции встраиваемой является только рекомендацией компилятору. Компилятор вполне может проигнорировать эту рекомендацию.
На самом деле механизм встраивания применяется в процессе оптимизации объектного кода, в ходе которого код небольших функций, вызов которых происходит достаточно часто, встраивается по месту вызова. Большинство компиляторов не будет встраивать рекурсивные функции. Функция на 75 строк также, вероятно, не будет встроена.
Функции constexpr
Функция constexpr — это функция, которая может быть применена в константном выражении (см. раздел 2.4.4). Функция constexpr определяется как любая другая функция, но должна соответствовать определенным ограничениям: возвращаемый тип и тип каждого параметра должны быть литералами (см. раздел 2.4.4), тело функции должно содержать только один оператор return:
constexpr int new_sz() { return 42; }
constexpr int foo = new_sz(); // ok: foo - константное выражение
Здесь функция new_sz определена как constexpr, она не получает никаких аргументов. Компилятор может проверить (во время компиляции), что вызов функции new_sz() возвращает константное выражение, поэтому ее можно использовать для инициализации переменной constexpr по имени foo.
Если это возможно, компилятор заменит вызов функции constexpr ее результирующим значением. Для этого функция constexpr неявно считается встраиваемой.
Тело функции constexpr может содержать другие операторы, если они не выполняют действий во время выполнения. Например, функция constexpr может содержать пустые операторы, псевдонимы типа (см. раздел 2.5.1) и объявления using.
Функции constexpr позволено возвратить значение, которое не является константой:
// scale(arg) - константное выражение, если arg - константное выражение
constexpr size_t scale(size_t cnt) { return new_sz() * cnt; }
Функция scale() возвратит константное выражение, если ее аргумент будет константным выражением, но не в противном случае:
int arr[scale(2)]; // ok: scale(2) - константное выражение
int i = 2; // i - неконстантное выражение
int a2[scale(i)]; // ошибка: scale(i) - неконстантное выражение
Если передать константное выражение (такое как литерал 2), возвращается тоже константное выражение. В данном случае компилятор заменит вызов функции scale() результирующим значением.
Если происходит вызов функции scale() с выражением, которое не является константным (например, объект i типа int), то возвращается неконстантное выражение. Если использовать функцию scale() в контексте, требующем константного выражения, компилятор проверит, является ли результат константным выражением. Если это не так, то компилятор выдаст сообщение об ошибке.
Функция constexpr не обязана возвращать константное выражение.
Помещайте встраиваемые функции и функции constexpr в файлы заголовка
В отличие от других функций, встраиваемые функции и функции constexpr могут быть определены в программе несколько раз. В конце концов, чтобы встроить код, компилятор нуждается в определении, а не только в объявлении. Однако все определения конкретной встраиваемой функции и функции constexpr должны совпадать точно. В результате встраиваемые функции и функции constexpr обычно определяют в заголовках.
Упражнения раздела 6.5.2
Упражнение 6.43. Какое из следующих объявлений и определений имеет смысл поместить в файл заголовка, а какой — в текст файла исходного кода? Объясните почему.
(a) inline bool eq(const BigInt&, const BigInt&) {...}
(b) void putValues(int *arr, int size);
Упражнение 6.44. Перепишите функцию isShorter() из раздела 6.2.2 как встраиваемую.
Упражнение 6.45. Пересмотрите функции, написанные для предыдущих упражнений, и решите, должны ли они быть определены как встраиваемые. Если да, то сделайте это. В противном случае объясните, почему они не должны быть встраиваемыми.
Упражнение 6.46. Возможно ли определить функцию isShorter как constexpr? Если да, то сделайте это. В противном случае объясните, почему нет.
6.5.3. Помощь в отладке
Для условного выполнения отладочного кода программисты С++ иногда используют подход, подобный защите заголовка (см. раздел 2.6.3). Идея в том, что программа будет содержать отладочный код, который выполняется только во время разработки программы. Когда приложение закончено и готово к выпуску, отладочный код исключается. Этот подход подразумевает использование двух средств препроцессора: assert и NDEBUG.
Макрос препроцессора assert
Макросassert — это макрос препроцессора (preprocessor macro). Макрос препроцессора — это переменная препроцессора, действующая как встраиваемая функция. Макрос assert получает одно выражение и использует его как условие:
assert(выражение);
Если результат выражения ложь (т.е. нуль), то макрос assert выдает сообщение и закрывает программу. Если результат выражения — истина (т.е. он отличен от нуля), то макрос assert не делает ничего.
Действие макроса препроцессора подобно вызову функции. Макрос assert получает одно выражение, которое он использует как условие.
Макрос assert определен в заголовке cassert. Как уже упоминалось, относящиеся к препроцессору имена обрабатывает препроцессор, а не компилятор (см. раздел 2.3.2). В результате такие имена можно использовать непосредственно, без объявления using. Таким образом, используется имя assert, а не std::assert, кроме того, для него не предоставляется объявление using.
Макрос assert зачастую используется для проверки "недопустимых" условий. Например, программа обработки вводимого текста могла бы проверять, что все вводимые слова длиннее некоего порогового значения. Эта программа могла бы содержать такой оператор:
assert(word.size() > threshold);
Переменная препроцессора NDEBUG
Поведение макроса assert зависит от состояния переменной препроцессора NDEBUG. Если переменная NDEBUG определена, макрос assert ничего не делает. По умолчанию переменная NDEBUG не определена, поэтому по умолчанию макрос assert выполняет проверку.
Отладку можно "выключить", предоставив директиву #define, определяющую переменную NDEBUG. В качестве альтернативы большинство компиляторов предоставляет параметр командной строки, позволяющий определять переменные препроцессора:
$ CC -D NDEBUG main.С # use /D with the Microsoft compiler
Результат будет тот же, что и при наличии строки #define NDEBUG в начале файла main.С.
Когда переменная NDEBUG определена, программа во время выполнения избегает дополнительных затрат на проверку различных условий. Самих проверок во время выполнения, конечно, тоже не будет. Поэтому макрос assert следует использовать только для проверки того, что действительно недопустимо. Это может быть полезно при отладке программы, но не должно использоваться для замены логических проверок времени выполнения или проверки ошибок, которые должна осуществлять программа.
В дополнение к макросу assert можно написать собственный отладочный код, выполняющийся в зависимости от переменной NDEBUG. Если переменная NDEBUG не определена, код между директивами #ifndef и #endif выполняется, а в противном случае игнорируется:
void print(const int ia[], size_t size) {
#ifndef NDEBUG
// __func__ - локальная статическая переменная, определенная
// компилятором. Она содержит имя функции
cerr << __func__ << ": array size is " << size << endl;
#endif
// ...
Здесь переменная __func__ используется для вывода имени отлаживаемой функции. Компилятор определяет переменную __func__ в каждой функции. Это локальный статический массив типа const char, содержащий имя функции.
Кроме переменной __func__, определяемой компилятором С++, препроцессор определяет четыре других имени, которые также могут пригодиться при отладке:
__FILE__ строковый литерал, содержащий имя файла.
__LINE__ целочисленный литерал, содержащий номер текущий строки.
__TIME__ строковый литерал, содержащий файл и время компиляции.
__DATE__ строковый литерал, содержащий файл и дату компиляции.
Эти константы можно использовать для отображения дополнительной информации в сообщениях об ошибках:
if (word.size() < threshold)
cerr << "Error: " << __FILE__
<< " : in function " << __func__
<< " at line " << __LINE__ << endl
<< " Compiled on " << __DATE__
<< " at " << __TIME__ << endl
<< " Word read was \"" << word << "\": Length too short" << endl;
Если передать этой программе строку, которая короче threshold, то будет создано следующее сообщение об ошибке:
Error: wdebug.cc : in function main at line 27
Compiled on Jul 11 2012 at 20:50:03
Word read was "foo": Length too short
Упражнения раздела 6.5.3
Упражнение 6.47. Пересмотрите программу, написанную в упражнении раздела 6.3.2, где использовалась рекурсия для отображения содержимого вектора так, чтобы условно отображать информацию о ее выполнении. Например, отобразите размер вектора при каждом вызове. Откомпилируйте и запустите программу с включенной отладкой и с выключенной.
Упражнение 6.48. Объясните, что делает этот цикл и стоит ли использовать в нем макрос assert:
string s;
while (cin >> s && s != sought) { } // пустое тело
assert(cin);
6.6. Подбор функции
Во многих (если не во всех) случаях довольно просто выяснить, какая из перегруженных версий функции будет использована при данном вызове. Но это не так просто, когда у перегруженных функций одинаковое количество параметров и когда один или несколько параметров имеют типы, связанные преобразованиями. Для примера рассмотрим следующий набор перегруженных функций и их вызов:
void f() ;
void f(int) ;
void f(int, int);
void f(double, double = 3.14);
f(5.6); // вызов void f(double, double)
Выявление кандидатов и подходящих функций
На первом этапе подбора перегруженной функции выявляют набор версий, подходящих для рассматриваемого вызова. Такие функции называются функциями-кандидатами (candidate function). Функция-кандидат имеет имя, указанное при вызове, и видима в точке вызова. В данном примере кандидатами являются все четыре функции по имени f.
На втором этапе выбора функции из набора кандидатов выявляются те, которые могут быть вызваны с аргументами данного вызова. Выбранные функции называют подходящими (viable function). Чтобы считаться подходящей, функция должна иметь столько же параметров, сколько аргументов передано при вызове, и тип каждого аргумента должен совпадать или допускать преобразование в тип соответствующего параметра.
При вызове f(5.6) две функции-кандидата можно исключить сразу из-за несоответствия количеству аргументов. Речь идет о версии без параметров и версии с двумя параметрами типа int. В данном случае вызов имеет только один аргумент, а эти функции не имеют их вообще или имеют два параметра соответственно.
Функция, получающая один аргумент типа int, и функция, получающая два аргумента типа double, могли бы быть подходящими. Любая из них может быть вызвана с одним аргументом. Функция, получающая два аргумента типа double, имеет аргумент по умолчанию, а значит, может быть вызвана с одним аргументом.
Когда у функции есть аргументы по умолчанию (см. раздел 6.5.1), при вызове может быть передано меньше аргументов, чем она фактически имеет.
После проверки количества аргументов, позволяющей выявить функции, подходящие потенциально, проверяется соответствие типов параметров функций типам аргументов, переданных при вызове. Как и при любом обращении, тип аргумента может либо совпадать, либо допускать преобразование в тип параметра. В данном случае подходят обе оставшиеся функции.
• Функция f(int) является подходящей потому, что аргумент типа double может быть неявно преобразован в параметр типа int.
• Функция f(double, double) также является подходящей потому, что для второго параметра задано значение по умолчанию, а первый параметр имеет тип double, который точно соответствует типу аргумента.
Если никаких подходящих функций не обнаружено, компилятор выдает сообщение об ошибке.
Поиск наилучшего соответствия, если он есть
На третьем этапе подбора перегруженной функции выясняется, какая из допустимых функций наилучшим образом соответствует вызову. Этот процесс анализирует каждый аргумент вызова и выбирает подходящую функцию (или функции), для которой соответствие параметра аргументу является наилучшим. Подробно критерии наилучшего соответствия рассматриваются в следующем разделе, а пока достаточно знать, что чем ближе типы аргумента и параметра друг к другу, тем лучше соответствие.
В данном случае существует только один (явный) аргумент, который имеет тип double. При вызове версии f(int) аргумент преобразуется из типа double в тип int. Вторая подходящая функция, f(double, double), точно соответствует типу этого аргумента. Поскольку точное соответствие лучше соответствия требующего преобразования, компилятор предпочитает версию с двумя параметрами типа double. Для второго, недостающего аргумента компилятор добавит аргумент по умолчанию.
Подбор перегруженной версии с несколькими параметрами
Если у функции два или несколько аргументов, подбор подходящей версии усложняется. Предположим, что функции имеют то же имя f, но анализируется следующий вызов:
f(42, 2.56);
Набор подходящих функций выявляется, как прежде. Компилятор выбирает те версии функции, которые имеют необходимое количество параметров, типы которых соответствуют типам аргументов. В данном случае в набор подходящих вошли функции f(int, int) и f(double, double). Затем компилятор перебирает аргументы один за одним и определяет, какая из версий функций имеет наилучшее соответствие. Наилучше соответствующая функция та, для которой единственной выполняются следующие условия.
• Соответствие по каждому аргументу не хуже, чем у остальных подходящих функций.
• По крайней мере у одного аргумента соответствие лучше, чем у остальных подходящих функций.
Если после просмотра всех аргументов не было найдено ни одной функции, которая считалась бы наилучше соответствующей, компилятор сообщает об ошибке неоднозначности вызова.
В рассматриваемом примере вызова анализ лишь первого аргумента для версии f(int, int) функции f() обнаруживает точное соответствие. При анализе второй версии функции f() оказывается, что аргумент 42 типа int следует преобразовать в значение типа double. Соответствие в результате встроенного преобразования хуже, чем точное. Таким образом, рассматривая только этот параметр, лучше соответствует та версия функции f(), которая обладает двумя параметрами типа int, а не двумя параметрами типа double.
Но при переходе ко второму аргументу оказывается, что версия функции f() с двумя параметрами типа double точно соответствует аргументу 2.56. Вызов версии функции f() с двумя параметрами типа int потребует преобразования аргумента 2.56 из типа double в тип int. Таким образом, при рассмотрении только второго параметра версия f(double, double) функции f() имеет лучшее соответствие.
Компилятор отклонит этот вызов, поскольку он неоднозначен: каждая подходящая функция является лучшим соответствием по одному из аргументов. Было бы заманчиво обеспечить соответствие за счет явного приведения типов (см. раздел 4.11.3) одного из аргументов. Но в хорошо спроектированных системах в приведении аргументов не должно быть необходимости.
При вызове перегруженных функций приведения аргументов практически не нужны: потребность в приведении означает, что наборы параметров перегруженных функций проработаны плохо.
Упражнения раздела 6.6
Упражнение 6.49. Что такое функция-кандидат? Что такое подходящая функция?
Упражнение 6.50. С учетом приведенных в начале раздела объявлений функции f() перечислите подходящие функции для каждого из следующих вызовов. Укажите наилучше соответствие, или если его нет, то из-за отсутствия соответствия или неоднозначности вызова?
(a) f(2.56, 42) (b) f(42) (с) f(42, 0) (d) f(2.56, 3.14)
Упражнение 6.51. Напишите все четыре версии функции f(). Каждая из них должна выводить собственное сообщение. Проверьте свои ответы на предыдущее упражнение. Если ответы были неправильными, перечитайте этот раздел и выясните, почему вы ошиблись.
6.6.1. Преобразование типов аргументов
Чтобы определить наилучшее соответствие, компилятор ранжирует преобразования, применяемые для приведения типа аргумента к типу соответствующего ему параметра. Преобразования ранжируются в порядке убывания следующим образом.
1. Точное соответствие. Типы аргумента и параметра совпадают в случае, если:
• типы аргумента и параметра идентичны;
• аргумент преобразуется из типа массива или функции в соответствующий тип указателя. (Указатели на функции рассматриваются в разделе 6.7);
• аргумент отличается наличием или отсутствием спецификатора const верхнего уровня.
2. Соответствие в результате преобразования констант (см. раздел 4.11.2).
3. Соответствие в результате преобразования (см. раздел 4.11.1).
4. Соответствие в результате арифметического преобразования (см. раздел 4.11.1) или преобразования указателя (см. раздел 4.11.2).
5. Соответствие в результате преобразования класса (раздел 14.9).
#magnify.png Соответствие, требующее приведения и (или) целочисленного преобразования
В контексте соответствия функций приведение и преобразование встроенных типов может привести к удивительным результатам. К счастью, в хорошо разработанных системах редко используют функции с параметрами, столь похожими, как в следующих примерах.
При анализе вызова следует помнить, что малые целочисленные типы всегда преобразуются в тип int или больший целочисленный тип. Рассмотрим две функции, одна из которых получает тип int, а вторая тип short, версия short будет вызвана только со значениями типа short. Даже при том, что меньшие целочисленные значения могли бы быть ближе к соответствию, эти значения преобразуются в тип int, тогда как вызов версии short потребовал бы преобразования:
void ff(int);
void ff(short);
ff('a'); // тип char приводится к int, поэтому применяется f(int)
Все целочисленные преобразования считаются эквивалентными друг другу. Преобразование из типа int в unsigned int, например, не имеет преимущества перед преобразованием типа int в double. Рассмотрим конкретный пример.
void manip(long);
void manip(float);
manip(3.14); // ошибка: неоднозначный вызов
Литерал 3.14 имеет тип double. Этот тип может быть преобразован или в тип long, или в тип float. Поскольку возможны два целочисленных преобразования, вызов неоднозначен.
Соответствие функций и константные аргументы
Когда происходит вызов перегруженной функции, различие между версиями которой заключается в том, указывает ли параметр (или ссылается) на константу, компилятор способен различать, является ли аргумент константным или нет:
Record lookup(Account&); // функция, получающая ссылку на Account
Record lookup(const Account&); // новая функция, получающая ссылку на
// константу
const Account а;
Account b;
lookup(а); // вызов lookup(const Account&)
lookup(b); // вызов lookup(Account&)
В первом вызове передается константный объект а. Нельзя связать простую ссылку с константным объектом. В данном случае единственная подходящая функция — версия, получающая ссылку на константу. Кроме того, этот вызов точно соответствует аргументу а.
Во втором вызове передается неконстантный объект b. Для этого вызова подходят обе функции. Аргумент b можно использовать для инициализации ссылки константного или неконстантного типа. Но инициализация ссылки на константу неконстантным объектом требует преобразования. Версия, получающая неконстантный параметр, является точным соответствием для объекта b. Следовательно, неконстантная версия предпочтительней.
Параметры в виде указателя работают подобным образом. Если две функции отличаются только тем, указывает ли параметр на константу или не константу, компилятор на основании константности аргумента вполне может решить, какую версию функции использовать: если аргумент является указателем на константу, то вызов будет соответствовать версии, получающей тип const*; в противном случае, если аргумент — указатель на не константу, вызывается версия, получающая простой указатель.
Упражнения раздела 6.6.1
Упражнение 6.52. Предположим, что существуют следующие объявления:
void manip(int, int);
double dobj;
Каков порядок (см. раздел 6.6.1) преобразований в каждом из следующих обращений?
(a) manip('a', 'z'); (b) manip(55.4, dobj);
Упражнение 6.53. Объясните назначение второго объявления в каждом из следующих наборов. Укажите, какие из них (если они есть) недопустимы.
(a) int calc(int&, int&);
int calc(const int&, const int&);
(b) int calc(char*, char*);
int calc(const char*, const char*);
(c) int calc(char*, char*);
int calc(char* const, char* const);
6.7. Указатели на функции
Указатель на функцию (function pointer) содержит адрес функции, а не объекта. Подобно любому другому указателю, указатель на функцию имеет вполне определенный тип. Тип функции определен типом ее возвращаемого значения и списком параметров. Имя функции не является частью ее типа.
// сравнивает длины двух строк
bool lengthCompare(const string &, const string &);
Эта функция имеет тип bool(const string&, const string&). Чтобы объявить указатель, способный указывать на эту функцию, достаточно расположить указатель вместо имени функции:
// pf указывает на функцию, получающую две константные ссылки
// на строки и возвращающую значение типа bool
bool (*pf)(const string &, const string &); // не инициализирован
Просматривая объявление с начала, можно заметить, что имени pf предшествует знак *, следовательно, pf — указатель. Справа расположен список параметров, означая, что pf указывает на функцию. Глядя влево, можно заметить, что возвращаемым типом функции является bool. Таким образом, указатель pf указывает на функцию, которая имеет два параметра типа const string& и возвращает значение типа bool.
Круглые скобки вокруг части *pf необходимы. Без них получится объявление функции pf(), возвращающей указатель на тип bool:
// объявление функции pf(), возвращающей указатель на тип bool
bool *pf(const string &, const string &);
Использование указателей на функцию
При использовании имени функции как значения функция автоматически преобразуется в указатель. Например, адрес функции lengthCompare() можно присвоить указателю pf следующим образом:
pf = lengthCompare; // pf теперь указывает на функцию lengthCompare
pf = &lengthCompare; // эквивалентное присвоение: оператор обращения к
// адресу необязателен
Кроме того, указатель на функцию можно использовать для вызова функции, на которую он указывает. Это можно сделать непосредственно, обращение к значению указателя там не обязательно:
bool b1 = pf("hello", "goodbye"); // вызов lengthCompare
bool b2 = (*pf)("hello", "goodbye"); // эквивалентный вызов
bool b3 = lengthCompare("hello", "goodbye"); // эквивалентный вызов
Преобразование указателя на один тип функции в указатель на другой тип функции невозможно. Однако для обозначения того, что указатель не указывает на функцию, ему можно присвоить nullptr (см. раздел 2.3.2) или целочисленное константное выражение, означающее нуль:
string::size_type sumLength(const string&, const string&);
bool cstringCompare(const char*, const char*);
pf = 0; // ok: pf не указывает на функцию
pf = sumLength; // ошибка: разные типы возвращаемого значения
pf = cstringCompare; // ошибка: разные типы параметров
pf = lengthCompare; // ok: типы функции и указателя совпадают точно
Указатели на перегруженные функции
Как обычно, при использовании перегруженной функции применяемую версию должен прояснить контекст, в котором она используется. Вот объявление указателя на перегруженную функцию:
void ff(int*);
void ff(unsigned int);
void (*pf1)(unsigned int) = ff; // pf1 указывает на ff(unsigned)
Компилятор использует тип указателя для выявления используемой версии перегруженной функции. Тип указателя должен точно соответствовать одной из версий перегруженной функции:
void (*pf2)(int) = ff; // ошибка: нет версии с точно таким списком
// параметров
double (*pf3) (int*) = ff; // ошибка: тип возвращаемого значения
// функций ff и pf3 не совпадают
Указатель на функцию как параметр
Подобно массивам (см. раздел 6.2.4), нельзя определить параметры типа функции, но можно создать параметр, являющийся указателем на функцию. Как и в случае с массивами, можно создать параметр, который выглядит как тип функции, но обрабатывается как указатель:
// третий параметр имеет тип функции и автоматически обрабатывается как
// указатель на функцию
void useBigger(const string &s1, const string &s2,
bool pf(const string&, const string&));
// эквивалентное объявление: параметр явно определен как указатель
// на функцию
void useBigger(const string &s1, const string &s2,
bool (*pf)(const string&, const string&));
При передаче функции как аргумента это можно сделать непосредственно. Аргумент будет автоматически преобразован в указатель:
// автоматическое преобразование функции lengthCompare в указатель
// на нее
useBigger(s1, s2, lengthCompare);
Как можно заметить в объявлении функции useBigger(), написание указателей на тип функций быстро становится утомительным. Псевдонимы типа (см. раздел 2.5.1), а также спецификатор decltype (см. раздел 2.5.3) позволяют упростить код, который использует указатели на функции:
// Func и Func2 имеют тип функции
typedef bool Func(const string&, const strings);
typedef decltype(lengthCompare) Func2; // эквивалентный тип
// FuncP и FuncP2 имеют тип указателя на функцию
typedef bool(*FuncP)(const string&, const string&);
typedef decltype(lengthCompare) *FuncP2; // эквивалентный тип
Здесь при определении типов использовано ключевое слово typedef. И Func, и Func2 являются типами функций, тогда как FuncP и FuncP2 — типы указателя. Следует заметить, что спецификатор decltype возвращает тип функции; автоматического преобразования в указатель не происходит. Поскольку спецификатор decltype возвращает тип функции, при необходимости получить указатель следует добавить символ *. Можно повторно объявить функцию useBigger(), используя любой из этих типов:
// эквивалентные объявления useBigger с использованием псевдонимов типа
void useBigger(const string&, const string&, Func);
void useBigger(const string&, const string&, FuncP2);
Оба объявления объявляют ту же функцию. В первом случае компилятор автоматически преобразует тип функции, представленный именем Func, в указатель.
Возвращение указателя на функцию
Подобно массивам (см. раздел 6.3.3), нельзя возвратить тип функции, но можно возвратить указатель на тип функции. Точно так же тип возвращаемого значения следует писать как тип указателя; компилятор не будет автоматически рассматривать тип возвращаемого значения функции как соответствующий тип указателя. Как и при возвращении массива, безусловно, проще всего объявить функцию, которая возвращает указатель на функцию, при помощи псевдонима типа:
using F = int(int*, int); // F - тип функции, а не указатель
using PF = int(*)(int*, int); // PF - тип указателя
Здесь для определения F как типа функции и PF как указателя на тип функции было использовано объявление псевдонима типа (см. раздел 2.5.1). Имейте в виду, что в отличие от параметров, имеющих тип функции, тип возвращаемого значения не преобразуется автоматически в тип указателя. Следует явно определить, что тип возвращаемого значения является типом указателя:
PF f1(int); // ok: PF - указатель на функцию; f1 возвращает указатель
// на функцию
F f1(int); // ошибка: F - тип функции; f1 не может возвратить функцию
F *f1(int); // ok: явное определение типа возвращаемого значения как
// указателя на функцию
Конечно, функцию f1() также можно объявить непосредственно:
int (*f1(int))(int*, int);
Читая это объявление изнутри наружу, можно заметить у f1 список параметров, таким образом, f1 — это функция. Имени f1 предшествует знак *, следовательно, функция f1() возвращает указатель. У типа самого указателя тоже есть список параметров, таким образом, указатель указывает на функцию. Эта функция возвращает тип int.
И наконец, следует обратить внимание на то, что объявления функций, которые возвращают указатель на функцию, можно упростить при помощи замыкающего типа возвращаемого значения (см. раздел 6.3.3):
auto f1(int) -> int (*)(int*, int);
Использование спецификаторов auto и decltype для типов указателей на функции
Если известно, какую функцию (функции) следует возвратить, можно использовать спецификатор decltype для упрощения записи типа возвращаемого значения в виде указателя на функцию. Предположим, например, что имеются две функции, обе возвращают тип string::size_type и имеют два параметра типа const string&. Можно написать третью функцию, которая получает параметр типа string и возвращает указатель на одну из следующих двух функций следующим образом:
string::size_type sumLength(const string&, const string&);
string::size_type largerLength(const string&, const string&);
// в зависимости от значения строкового параметра функция getFcn
// возвращает указатель на sumLength или largerLength
decltype(sumLength) *getFcn(const string &);
Единственная сложность в объявлении функции getFcn() — это необходимость помнить, что при применении спецификатора decltype к функции она возвращает тип функции, а не указатель на тип функции. Чтобы получить указатель, а не функцию, следует добавить знак *.
Упражнения раздела 6.7
Упражнение 6.54. Напишите объявление функции, получающей два параметра типа int, и возвращающей тип int. Объявите также вектор, элементами которого является тип указателя на эту функцию.
Упражнение 6.55. Напишите четыре функции, которые добавляют, вычитают, умножают и делят два значения типа int. Сохраните указатели на эти значения в векторе из предыдущего упражнения.
Упражнение 6.56. Обратитесь к каждому элементу вектора и выведите результат.
Резюме
Функции представляют собой именованные блоки действий, применяемые для структурирования даже небольших программ. При их определении указывают тип возвращаемого значения, имя, список параметров (возможно, пустой) и тело функции. Тело функции — это блок операторов, выполняемых при вызове функции. Переданные функции при вызове аргумента должны быть совместимы с типами соответствующих параметров.
В языке С++ функции могут быть перегружены. То есть одинаковое имя может быть использовано при определении разных функций, отличающихся количеством или типами параметров. На основании переданных при вызове аргументов компилятор автоматически выбирает наиболее подходящую версию функции. Процесс выбора правильной версии из набора перегруженных функций называют подбором функции с наилучшим соответствием.
Термины
Автоматический объект (automatic object). Объект, являющийся для функции локальным. Автоматические объекты создаются и инициализируются при каждом обращении и удаляются по завершении блока, в котором они были определены.
Аргумент (argument). Значение, предоставляемое при вызове функции для инициализации соответствующих параметров.
Аргумент по умолчанию (default argument). Значение, определенное для использования, когда аргумент пропущен при вызове функции.
Бесконечная рекурсия (recursion loop). Когда у рекурсивной функции отсутствует условие остановки, она вызывает сама себя до исчерпания стека программы.
Встраиваемая функция (inline function). Функция, тело которой встраивается по месту обращения, если это возможно. Встраиваемые функции позволяют избежать обычных дополнительных затрат, поскольку их вызов заменяет код тела функции.
Вызов по значению (call by value). См. передача по значению.
Вызов по ссылке (call by reference). См. передача по ссылке.
Исполняемый файл (executable file). Файл, содержащий программный код, который может быть выполнен операционной системой.
Классinitializer_list. Библиотечный класс, представляющий разделяемый запятыми список объектов одинакового типа, заключенный в фигурные скобки.
Компоновка (link). Этап компиляции, на котором несколько объектных файлов объединяются в исполняемую программу.
Локальная переменная (local variable). Переменные, определенные в блоке.
Локальный статический объект (local static object). Локальный объект, который создается и инициализируется только один раз перед первым вызовом функции, в которой используется ее значение. Значение локального статического объекта сохраняется на протяжении всех вызовов функции.
Макросassert. Макрос препроцессора, который получает одно выражение, используемое в качестве условия. Если переменная препроцессора NDEBUG не определена, макрос assert проверяет условие. Если оно ложно, макрос assert выводит сообщение и завершает программу.
Макрос препроцессора (preprocessor macro). Средство препроцессора, ведущее себя как встраиваемая функция. Кроме макроса assert, современные программы С++ очень редко используют макросы препроцессора.
Наилучшее соответствие (best match). Функция, выбранная для вызова из набора перегруженных версий. Если наилучшее соответствие существует, выбранная функция лучше остальных подходит по крайней мере для одного аргумента вызова и не хуже остальных версий для оставшейся части аргументов.
Неоднозначный вызов (ambiguous call). Ошибка времени компиляции, происходящая при поиске подходящей функции, когда две или более функции обеспечивают одинаково хорошее соответствие для вызова.
Объектный код (object code). Формат, в который компилятор преобразует исходный код.
Объектный файл (object file). Файл, содержащий объектный код, созданный компилятором из предоставленного файла исходного кода. Исполняемый файл создается в результате компоновки одного или нескольких объектных файлов.
Оператор(). Оператор вызова. Запускает функцию на выполнение. Круглые скобки, следующие за именем функции или указателем на функцию, заключают разделяемый запятыми список аргументов, который может быть пуст.
Отсутствие соответствия (no match). Ошибка времени компиляции, происходящая при поиске подходящей функции, когда не обнаружено ни одной функции с параметрами, которые соответствуют аргументам при данном вызове.
Параметр (parameter). Локальная переменная, объявляемая в списке параметров функции. Параметры инициализируются аргументами, предоставляемыми при каждом вызове функции.
Перегруженная функция (overloaded function). Функция, которая имеет то же имя, что и по крайней мере одна другая функция. Перегруженные функции должны отличаться по количеству или типу их параметров.
Передача по значению (pass by value). Способ передачи аргументов параметрам не ссылочного типа. Не ссылочный параметр — это копия значения соответствующего аргумента.
Передача по ссылке (pass by reference). Способ передачи аргументов параметрам ссылочного типа. Ссылочные параметры работают так же, как и любая другая ссылка; параметр связан со своим аргументом.
Замыкающий тип возвращаемого значения (trailing return type). Тип возвращаемого значения, определенный после списка параметров.
Подбор функции (function matching). Процесс, в ходе которого компилятор ассоциирует вызов функции с определенной версией из набора перегруженных функций. При подборе функции используемые в обращении аргументы сравниваются со списком параметров каждой версии перегруженной функции.
Подходящая функция (viable function). Подмножество перегруженных функций, которые могли бы соответствовать данному вызову. У подходящих функций количество параметров совпадает с количеством переданных при обращении аргументов, а тип каждого аргумента может быть преобразован в тип соответствующего параметра.
Поиск перегруженной функции (overload resolution). См. подбор функции.
Продолжительность существования объекта (object lifetime). Каждый объект характеризуется своей продолжительностью существования. Нестатические объекты, определенные в блоке, существуют от момента их определения и до конца блока, в котором они определены. Глобальные объекты создаются во время запуска программы. Локальные статические объекты создаются прежде, чем выполнение впервые пройдет через определение объекта. Глобальные объекты и локальные статические объекты удаляются по завершении функции main().
Прототип функции (function prototype). Синоним объявления функции. В прототипе указано имя, тип возвращаемого значения и типы параметров функции. Чтобы функцию можно было вызвать, ее прототип должен быть объявлен перед точкой обращения.
Раздельная компиляция (separate compilation). Способность разделить программу на несколько отдельных файлов исходного кода.
Рекурсивная функция (recursive function). Функция, которая способна вызвать себя непосредственно или косвенно.
Скрытое имя (hidden name). Имя, объявленное в области видимости, но скрытое ранее объявленной сущностью с тем же именем, объявленным вне этой области видимости.
Тело функции (function body). Блок операторов, в котором определены действия функции.
Тип возвращаемого значения (return type). Часть объявления функции, определяющее тип значения, которое возвращает функция.
Функцияconstexpr. Функция, способная возвратить константное выражение. Функция constexpr неявно является встраиваемой.
Функция (function). Именованный блок действий.
Функция-кандидат (candidate function). Одна из функций набора, рассматриваемая при поиске соответствия вызову функции. Кандидатами считаются все функции, объявленные в области видимости обращения, имя которых совпадает с используемым в обращении.
Глава 7
Классы
Классы в языке С++ используются для определения собственных типов данных. Определение типов, отражающих концепции решаемых задач, позволяет существенно упростить написание, отладку и модификацию программ.
В этой главе будет продолжено описание классов, начатое в главе 2. Основное внимание здесь уделяется важности абстракции данных, позволяющей отделять реализацию объекта от операций, в которых объект может участвовать. В главе 13 будет описано, как контролировать происходящее при копировании, перемещении, присвоении и удалении объекта, а в главе 14 рассматривается определение собственных операторов.
Фундаментальными идеями, лежащими в основе концепции классов (class), являются абстракция данных (data abstraction) и инкапсуляция (encapsulation). Абстракция данных — программный подход, полагающийся на разделение интерфейса (interface) и реализации (implementation). Интерфейс класса состоит из операций, которые пользователь класса может выполнить с его объектом. Реализация включает переменные-члены класса, тела функций, составляющих интерфейс, а также любые функции, которые нужны для определения класса, но не предназначены для общего использования.
Инкапсуляция обеспечивает разделение интерфейса и реализации класса. Инкапсулируемый класс скрывает свою реализацию от пользователей, которые могут использовать интерфейс, но не имеют доступа к реализации класса.
Класс, использующий абстракцию данных и инкапсуляцию, называют абстрактным типом данных (abstract data type). Внутренняя реализация абстрактного типа данных заботит только его разработчика. Программисты, которые используют этот класс, не обязаны ничего знать о том, как внутренне работает этот тип. Они могут рассматривать его как абстракцию.
7.1. Определение абстрактных типов данных
Класс Sales_item, использованный в главе 1, является абстрактным типом данных. При использовании объекта класса Sales_item задействовался его интерфейс (т.е. операции, описанные в разделе 1.5.1). Мы не имели доступа к переменным-членам, хранящимся в объекте класса Sales_item. На самом деле нам даже не было известно, какие переменные-члены имеет этот класс.
Наш класс Sales_data (см. раздел 2.6.1) не был абстрактным типом данных. Он позволяет пользователям обращаться к его переменным-членам и вынуждает пользователей писать собственные операции. Чтобы сделать класс Sales_data абстрактным типом, необходимо определить операции, доступные для его пользователей. Как только класс Sales_data определит собственные операции, мы сможем инкапсулировать (т.е. скрыть) его переменные-члены.
7.1.1. Разработка класса
Sales_data
В конечном счете хочется, чтобы класс Sales_data поддержал тот же набор операций, что и класс Sales_item. У класса Sales_item была одна функция-член (member function) (см. раздел 1.5.2) по имени isbn, а также поддерживались операторы +, =, +=, << и >>.
Определение собственных операторов рассматривается в главе 14, а пока определим обычные (именованные) функции для этих операций. По причинам, рассматриваемым в разделе 7.1.5, функции, осуществляющие сложение и операции ввода-вывода, не будут членами класса Sales_data. Мы определим эти функции как обычные. Функция, выполняющая составное присвоение, будет членом класса, и по причинам, рассматриваемым в разделе 7.1.5, наш класс не должен определять присвоение.
Таким образом, интерфейс класса Sales_data состоит из следующих операций.
• Функция-член isbn(), возвращающая ISBN объекта.
• Функция-член combine(), добавляющая один объект класса Sales_data к другому.
• Функция add(), суммирующая два объекта класса Sales_data.
• Функция read(), считывающая данные из потока istream в объект класса Sales_data.
• Функция print(), выводящая значение объекта класса Sales_data в поток ostream.
Ключевая концепция. Различие в ролях программистов
Пользователями (user) программисты обычно называют людей, использующих их приложения. Аналогично разработчик класса реализует его для пользователей класса. В данном случае пользователем является другой программист, а не конечный пользователь приложения.
Когда упоминается пользователь , имеющееся в виду лицо определяет контекст употребления термина. Если речь идет о пользовательском коде или пользователе класса Sales_data , то подразумевается программист, который использует класс. Если речь идет о пользователе приложения книжного магазина, то подразумевается менеджер склада, использующий приложение.
#sheet.jpg Говоря о пользователях , программисты С++, как правило, имеют в виду как пользователей приложения, так и пользователей класса.
В простых приложениях пользователь класса вполне может быть и его разработчиком. Но даже в таких случаях имеет смысл различать роли. Разрабатывая интерфейс класса, следует думать о том, чтобы его было проще использовать. При использовании класса не нужно думать, как именно он работает.
Авторы хороших приложений добиваются успеха потому, что понимают и реализуют потребности пользователей. Точно так же хорошие разработчики класса обращают пристальное внимание на потребности программистов, которые будут использовать их класс. У хорошо разработанного класса удобный, интуитивно понятный интерфейс, а его реализация достаточно эффективна для решения задач пользователя.
Использование пересмотренного класса Sales_data
Прежде чем думать о реализации нашего класса, обдумаем то, как можно использовать функции его интерфейса. В качестве примера использования этих функций напишем новую версию программы книжного магазина из раздела 1.6, работающую с объектами класса Sales_data, а не Sales_item:
Sales_data total; // переменная для хранения текущей суммы
if (read(cin, total)) { // прочитать первую транзакцию
Sales_data trans; // переменная для хранения данных следующей
// транзакции
while(read(cin, trans)) { // читать остальные транзакции
if (total.isbn() == trans.isbn()) // проверить isbn
total.combine(trans); // обновить текущую сумму
else {
print(cout, total) << endl; // отобразить результаты
total = trans; // обработать следующую книгу
}
}
print(cout, total) << endl; // отобразить последнюю транзакцию
} else { // ввода нет
cerr << "No data?!" << endl; // уведомить пользователя
}
Сначала определяется объект класса Sales_data для хранения текущей суммы. В условии оператора if происходит вызов функции read() для чтения в переменную total первой транзакции. Это условие работает, как и другие написанные ранее циклы с использованием оператора >>. Как и оператор >>, наша функция read() будет возвращать свой потоковый параметр, который и проверяет условие (см. раздел 4.11.2). Если функция read() потерпит неудачу, сработает часть else, выводящая сообщение об ошибке.
Если данные прочитаны успешно, определяем переменную trans для хранения всех транзакций. Условие цикла while также проверяет поток, возвращенный функцией read(). Пока операции ввода в функции read() успешны, условие выполняется и обрабатывается следующая транзакция.
В цикле while происходит вызов функции-члена isbn() объектов total и trans, возвращающей их ISBN. Если объекты total и trans относятся к той же книге, происходит вызов функции combine(), добавляющей компоненты объекта trans к текущей сумме, хранящейся в объекте total. Если объект trans представляет новую книгу, происходит вызов функции print(), выводящей итог по предыдущей книге. Поскольку функция print() возвращает ссылку на свой потоковый параметр, ее результат можно использовать как левый операнд оператора <<. Это сделано для того, чтобы вывести символ новой строки после результата, созданного функцией print(). Затем объект trans присваивается объекту total, начиная таким образом обработку записи следующей книги в файле.
По исчерпании ввода следует не забыть вывести данные последней транзакции. Для этого после цикла while используется еще один вызов функции print().
Упражнения раздела 7.1.1
Упражнение 7.1. Напишите версию программы обработки транзакций из раздела 1.6 с использованием класса Sales_data, созданного для упражнений в разделе 2.6.1.
7.1.2. Определение пересмотренного класса
Sales_data
У пересмотренного класса будут те же переменные-члены, что и у версии, определенной в разделе 2.6.1: член типа string по имени bookNo, представляющий ISBN, член типа unsigned по имени units_sold, представляющий количество проданных экземпляров книги, и член типа double по имени revenue, представляющий общий доход от этих продаж.
Как уже упоминалось, у класса будут также две функции-члена, combine() и isbn(). Кроме того, предоставим классу Sales_data другую функцию-член, чтобы возвращать среднюю цену, по которой были проданы книги. Эта функция, назовем ее avg_price(), не предназначена для общего использования. Она будет частью реализации, а не интерфейса.
Функции-члены определяют (см. раздел 6.1) и объявляют (см. раздел 6.1.2) как обычные функции. Функции-члены должны быть объявлены в классе, но определены они могут быть непосредственно в классе или вне тела класса. Функции, не являющиеся членами класса, но являющиеся частью интерфейса, как функции add(), read() и print(), объявляются и определяются вне класса.
С учетом вышеизложенного напишем пересмотренную версию класса Sales_data:
struct Sales_data {
// новые члены: операции с объектами класса Sales_data
std::string isbn() const { return bookNo; }
Sales_data& combine(const Sales_data&);
double avg_price() const;
// те же переменные-члены, что и в p. 2.6.1
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};
// функции интерфейса класса Sales_data, не являющиеся его членами
Sales_data add(const Sales_data&, const Sales_data&);
std::ostream &print(std::ostream&, const Sales_data&);
std::istream &read(std::istream&, Sales_data&);
Функции, определенные в классе, неявно являются встраиваемыми (см. раздел 6.5.2).
Определение функций-членов
Хотя каждый член класса должен быть объявлен в самом классе, тело функции-члена можно определить либо в, либо вне тела класса. Функция isbn() определяется в классе Sales_data, а функции combine() и avg_price() вне его.
Сначала рассмотрим функцию isbn(), возвращающую строку и имеющую пустой список параметров:
std::string isbn() const { return bookNo; }
Как и у любой функции, тело функции-члена является блоком. В данном случае блок содержит один оператор return, возвращающий значение переменной-члена bookNo объекта класса Sales_data. Интересно, как эта функция получает объект, член bookNo которого следует выбрать?
Указатель this
Давайте снова рассмотрим вызов функции-члена isbn():
total.isbn()
Здесь для вызова функции-члена isbn() объекта total используется точечный оператор (см. раздел 4.6).
За одним исключением, рассматриваемым в разделе 7.6, вызов функции-члена осуществляется от имени объекта. Когда функция isbn() обращается к члену класса Sales_data (например, bookNo), она неявно обращается к членам того объекта, из которого была вызвана. В этом вызове функции isbn(), когда она возвращает значение члена bookNo, речь идет о члене total.bookNo.
Функция-член способна обратиться к тому объекту, из которого она была вызвана, благодаря дополнительному неявному параметру this. Когда происходит вызов функции-члена, указатель this инициализируется адресом объекта, из которого была вызвана функция. Рассмотрим следующий вызов:
total.isbn()
Здесь компилятор присваивает адрес объекта total указателю this и неявно передает его как параметр функции isbn(). Компилятор как бы переписывает этот вызов так:
// псевдокод, в который преобразуется вызов функции-члена
Sales_data::isbn(&total)
Этот код вызывает функцию-член isbn() класса Sales_data, передав адрес объекта total.
В функции-члене можно обратиться непосредственно к членам объекта, из которого она была вызвана. Для использования членов объекта, на который указывает указатель this, можно не использовать оператор доступа к члену. Любое непосредственное использование члена класса подразумевает использование указателя this. Таким образом, когда функция isbn() использует переменную bookNo, она неявно использует член объекта, на который указывает указатель this. Это аналогично синтаксису this->bookNo.
Параметр this определяется неявно и автоматически. Кроме того, определить параметр или переменную по имени this самому нельзя, но в теле функции-члена его использовать можно. Вполне допустимо, хоть и не нужно, определить функцию isbn() так:
std::string isbn() const { return this->bookNo; }
Поскольку указатель this всегда предназначен для обращения к "этому" объекту, он является константным (см. раздел 2.4.2). Нельзя изменить адрес, хранящийся в указателе this.
Константные функции-члены
Еще одним важным моментом функции-члена isbn() является ключевое слово const, расположенное за списком параметров. Оно применяется для модификации типа неявного указателя this.
По умолчанию указатель this имеет тип константного указателя на неконстантную версию типа класса. Например, типом по умолчанию указателя this в функции-члене Sales_data является Sales_data *const. Хоть указатель this и неявен, он подчиняется обычным правилам инициализации, согласно которым (по умолчанию) нельзя связать указатель this с константным объектом (см. раздел 2.4.2). Следствием этого факта, в свою очередь, является невозможность вызвать обычную функцию-член для константного объекта.
Если бы функция isbn() была обычной и если бы указатель this был обычным параметром типа указателя, то мы объявили бы его как const Sales_data *const. В конце концов, тело функции isbn() не изменяет объект, на который указывает указатель this; таким образом, эта функция стала бы гибче, если бы указатель this был указателем на константу (см. раздел 6.2.3).
Однако указатель this неявный и не присутствует в списке параметров, поэтому нет места, где можно было бы указать, что он должен быть указателем на константу. Язык решает эту проблему, позволяя разместить ключевое слово const после списка параметров функции-члена. Это означает, что указатель this является указателем на константу. Функции-члены, использующие ключевое слово const таким образом, являются константными функциями-членами (const member function).
Тело функции isbn() можно считать написанным так:
// псевдокод, иллюстрирующий использование неявного указателя
// этот код недопустим: нельзя самому явно определить этот указатель
// обратите внимание, что это указатель на константу, поскольку isbn()
// является константным членом класса
std::string Sales_data::isbn(const Sales_data *const this)
{ return this->isbn; }
Тот факт, что this является указателем на константу, означает, что константные функции-члены не могут изменить объект, для которого они вызваны. Таким образом, функция isbn() может читать значения переменных- членов объектов, для которых она вызывается, но не изменять их.
Константные объекты, ссылки и указатели на константные объекты могут вызывать только константные функции-члены
Область видимости класса и функции-члены
Помните, что класс сам является областью видимости (см. раздел 2.6.1). Определения функций-членов класса находятся в области видимости самого класса. Следовательно, использованное функцией isbn() имя bookNo относится к переменной-члену, определенной в классе Sales_data.
Следует заметить, что функция isbn() может использовать имя bookNo, несмотря на то, что оно определено после функции isbn(). Как будет описано в разделе 7.4.1, компилятор обрабатывает классы в два этапа — сначала объявления членов класса, затем тела функций-членов, если таковые вообще имеются. Таким образом, тела функций-членов могут использовать другие члены своих классов, независимо от того, где именно в классе они определены.
Определение функции-члена вне класса
Подобно любой другой функции, при определении функции-члена вне тела класса ее определение должно соответствовать объявлению. Таким образом, тип возвращаемого значения, список параметров и имя должны совпадать с объявлением в теле класса. Если член класса был объявлен как константная функция, то в определении после списка параметров также должно присутствовать ключевое слово const. Имя функции-члена, определенное вне класса, должно включить имя класса, которому она принадлежит:
double Sales_data::avg_price() const {
if (units_sold)
return revenue/units_sold;
else
return 0;
}
Имя функции, Sales data::avg_price(), использует оператор области видимости (см. раздел 1.2), чтобы указать, что определяемая функция по имени avg_price объявлена в пределах класса Sales_data. Как только компилятор увидит имя функции, остальная часть кода интерпретируется как относящаяся к области видимости класса. Таким образом, когда функция avg_price() обращается к переменным revenue и units_sold, она неявно имеет в виду члены класса Sales_data.
Определение функции, возвращающей указатель this на объект
Функция combine() должна действовать как составной оператор присвоения +=. Объект, для которого вызвана эта функция, представляет собой левый операнд присвоения. Правый операнд передается как аргумент явно:
Sales_data& Sales_data::combine(const Sales_data &rhs) {
units_sold += rhs.units_sold; // добавить члены объекта rhs
revenue += rhs.revenue; // к членам объекта 'this'
return *this; // возвратить объект, для которого вызвана функция
}
Когда наша программа обработки транзакций осуществляет вызов
total.combine(trans); // обновить текущую сумму
адрес объекта total находится в неявном параметре this, а объект trans связан с параметром rhs. Таким образом, при вызове функции combine() выполняется следующий оператор:
units_sold += rhs.units_sold; // добавить члены объекта rhs
В результате произойдет сложение переменных total.units_sold и trans.units_sold, а сумма должна сохраниться снова в переменной total.units_sold.
Самым интересным в этой функции является тип возвращаемого значения и оператор return. Обычно при определении функции, работающей как стандартный оператор, она должна подражать поведению этого оператора. Стандартные операторы присвоения возвращают свой левый операнд как l-значение (см. раздел 144). Чтобы возвратить l-значение, наша функция combine() должна возвратить ссылку (см. раздел 6.3.2). Поскольку левый операнд — объект класса Sales_data, тип возвращаемого значения — Sales_data&.
Как уже упоминалось, для доступа к члену объекта, функция-член которого выполняется, необязательно использовать неявный указатель this. Однако для доступа к объекту в целом указатель this действительно нужен:
return *this; // возвратить объект, для которого вызвана функция
Здесь оператор return обращается к значению указателя this, чтобы получить объект, функция которого выполняется. Таким образом, для этого вызова возвращается ссылка на объект total.
Упражнения раздела 7.1.2
Упражнение 7.2. Добавьте функции-члены combine() и isbn() в класс Sales_data, который был написан для упражнений из раздела 2.6.2.
Упражнение 7.3. Пересмотрите свою программу обработки транзакций из раздела 7.1.1 так, чтобы использовать эти функции-члены.
Упражнение 7.4. Напишите класс по имени Person, представляющий имя и адрес человека. Используйте для содержания каждого из этих членов тип string. В последующих упражнениях в этот класс будут добавлены новые средства.
Упражнение 7.5. Снабдите класс Person операциями возвращения имени и адреса. Должны ли эти функции быть константами? Объясните свой выбор.
7.1.3. Определение функций, не являющихся членом класса, но связанных с ним
Авторы классов нередко определяют такие вспомогательные функции, как наши функции add(), read() и print(). Хотя определяемые ими операции концептуально являются частью интерфейса класса, частью самого класса они не являются.
Мы определяем функции, не являющиеся членом класса, как любую другую функцию, т.е. ее объявление обычно отделено от определения (см. раздел 6.1.2). Функции, концептуально являющиеся частью класса, но не определенные в нем, как правило, объявляются (но не определяются) в том же заголовке, что и сам класс. Таким образом, чтобы использовать любую часть интерфейса, пользователю достаточно включить только один файл.
Обычно функция, не являющаяся членом класса, но из состава его интерфейса объявляется в том же заголовке, что и сам класс.
Определение функций read() и print()
Функции read() и print() выполняют ту же задачу, что и код в разделе 2.6.2, поэтому и не удивительно, что тела этих функций очень похожи на код, представленный там:
// введенные транзакции содержат ISBN, количество проданных книг и
// цену книги
istream &read(istream &is, Sales_data &item) {
double price = 0;
is >> item.bookNo >> item.units_sold >> price;
item.revenue = price * item.units_sold;
return is;
}
ostream &print(ostream &os, const Sales_data &item) {
os << item.isbn() << " " << item.units_sold << " "
<< item.revenue << " " << item.avg_price();
return os;
}
Функция read() читает данные из предоставленного потока в заданный объект. Функция print() выводит содержимое предоставленного объекта в заданный поток.
В этих функциях, однако, следует обратить внимание на два момента. Во- первых, обе функции получают ссылки на соответствующие объекты классов ввода и вывода. Классы ввода и вывода — это типы, не допускающие копирования, поэтому их передача возможна только по ссылке (см. раздел 6.2.2). Кроме того, чтение и запись в поток изменяют его, поэтому обе функции получают обычные ссылки, а не ссылки на константы.
Второй заслуживающий внимания момент: функция print() не выводит новую строку. Обычно функции вывода осуществляют минимальное форматирование. Таким образом, пользовательский код может сам решить, нужна ли новая строка.
Определение функции add()
Функция add() получает два объекта класса Sales_data и возвращает новый объект класса Sales_data, представляющий их сумму:
Sales_data add(const Sales_data &lhs, const Sales_data &rhs) {
Sales_data sum = lhs; // копирование переменных-членов из lhs в sum
sum.combine(rhs); // добавить переменные-члены rhs в sum
return sum;
}
В теле функции определяется новый объект класса Sales_data по имени sum, предназначенный для хранения суммы двух транзакций. Инициализируем объект sum копией объекта lhs. По умолчанию копирование объекта класса подразумевает копирование и членов этого объекта. После копирования у членов bookNo, units_sold и revenue объекта sum будут те же значения, что и у таковых объекта lhs. Затем происходит вызов функции combine(), суммирующей значения переменных-членов units_sold и revenue объектов rhs и sum в последний. По завершении возвращается копия объекта sum.
Упражнения раздела 7.1.3
Упражнение 7.6. Определите собственные версии функций add(), read() и print().
Упражнение 7.7. Перепишите программу обработки транзакций, написанной для упражнений в разделе 7.1.2, так, чтобы использовать эти новые функции.
Упражнение 7.8. Почему функция read() определяет свой параметр типа Sales_data как простую ссылку, а функция print() — как ссылку на константу?
Упражнение 7.9. Добавьте в код, написанный для упражнений в разделе 7.1.2, операции чтения и вывода объектов класса Person.
Упражнение 7.10. Что делает условие в следующем операторе if?
if (read(read(cin, data1), data2))
7.1.4. Конструкторы
Каждый класс определяет, как могут быть инициализированы объекты его типа. Класс контролирует инициализацию объекта за счет определения одной или нескольких специальных функций-членов, известных как конструкторы (constructor). Задача конструктора — инициализировать переменные-члены объекта класса. Конструктор выполняется каждый раз, когда создается объект класса.
В этом разделе рассматриваются основы определения конструкторов. Конструкторы — удивительно сложная тема. На самом деле мы сможем больше сказать о конструкторах в разделах 7.5, 15.7 и 18.1.3, а также в главе 13.
Имя конструкторов совпадает с именем класса. В отличие от других функций, у конструкторов нет типа возвращаемого значения. Как и другие функции, конструкторы имеют (возможно пустой) список параметров и (возможно пустое) тело. У класса может быть несколько конструкторов. Подобно любой другой перегруженной функции (см. раздел 6.4), конструкторы должны отличаться друг от друга количеством или типами своих параметров.
В отличие от других функций-членов, конструкторы не могут быть объявлены константами (см. раздел 7.1.2). При создании константного объекта типа класса его константность не проявится, пока конструктор не закончит инициализацию объекта. Таким образом, конструкторы способны осуществлять запись в константный объект во время его создания.
#magnify.png Синтезируемый стандартный конструктор
Хотя в нашем классе Sales_data не определено конструкторов, использующие его программы компилировались и выполнялись правильно. Например, программа из раздела 7.1.1 определяла два объекта класса Sales_data:
Sales_data total; // переменная для хранения текущей суммы
Sales_data trans; // переменная для хранения данных следующей
// транзакции
Естественно, возникает вопрос: как инициализируются объекты total и trans?
Настолько известно, инициализатор для этих объектов не предоставлялся, поэтому они инициализируются значением по умолчанию (см. раздел 2.2.1). Классы сами контролируют инициализацию по умолчанию, определяя специальный конструктор, известный как стандартный конструктор (default constructor). Стандартным считается конструктор, не получающий никаких аргументов.
Как будет продемонстрировано, стандартный конструктор является особенным во многом, например, если класс не определяет конструкторы явно, компилятор сам определит стандартный конструктор неявно.
Созданный компилятором конструктор известен как синтезируемый стандартный конструктор (synthesized default constructor). У большинства классов этот синтезируемый конструктор инициализирует каждую переменную-член класса следующим образом:
• Если есть внутриклассовый инициализатор (см. раздел 2.6.1), он и используется для инициализации члена класса.
• В противном случае член класса инициализируется значением по умолчанию (см. раздел 2.2.1).
Поскольку класс Sales_data предоставляет инициализаторы для переменных units_sold и revenue, синтезируемый стандартный конструктор использует данные значения для инициализации этих членов. Переменная bookNo инициализируется значением по умолчанию, т.е. пустой строкой.
Некоторые классы не могут полагаться на синтезируемый стандартный конструктор
Только довольно простые классы, такие как текущий класс Sales_data, могут полагаются на синтезируемый стандартный конструктор. Как правило, собственный стандартный конструктор для класса определяют потому, что компилятор создает его, только если для класса не определено никаких других конструкторов. Если определен хоть один конструктор, то у класса не будет стандартного конструктора, если не определить его самостоятельно. Основание для этого правила таково: если класс требует контроля инициализации объекта в одном случае, то он, вероятно, потребует его во всех случаях.
Компилятор создает стандартный конструктор автоматически, только если в классе не объявлено никаких конструкторов.
Вторая причина для определения стандартного конструктора в том, что у некоторых классов синтезируемый стандартный конструктор работает неправильно. Помните, что определенные в блоке объекты встроенного или составного типа (такого как массивы и указатели) без инициализации имеют неопределенное значение (см. раздел 2.2.1). Это же относится к не инициализированным членам встроенного типа. Поэтому классы, у которых есть члены встроенного или составного типа, должны либо инициализировать их в классе, либо определять собственную версию стандартного конструктора. В противном случае пользователи могли бы создать объекты с членами, значения которых не определены.
Классы, члены которых имеют встроенный или составной тип, могут полагаться на синтезируемый стандартный конструктор, только если у всех таких членов есть внутриклассовые инициализаторы.
Третья причина определения некоторыми классами собственного стандартного конструктора в том, что иногда компилятор неспособен создать его. Например, если у класса есть член типа класса и у этого класса нет стандартного конструктора, то компилятор не сможет инициализировать этот член. Для таких классов следует определить собственную версию стандартного конструктора. В противном случае у класса не будет пригодного для использования стандартного конструктора. Дополнительные обстоятельства, препятствующие компилятору создать соответствующий стандартный конструктор, приведены в разделе 13.1.6.
Определение конструкторов класса Sales_data
Определим для нашего класса Sales_data четыре конструктора со следующими параметрами:
• Типа istream&, для чтения транзакции.
• Типа const string& для ISBN; типа unsigned для количества проданных книг; типа double для цены проданной книги.
• Типа const string& для ISBN. Для других членов этот конструктор будет использовать значения по умолчанию.
• Без параметров (т.е. стандартный конструктор). Этот конструктор придется определить, поскольку определены другие конструкторы.
Добавим эти члены в класс так:
struct Sales_data {
// добавленные конструкторы
Sales_data() = default;
Sales_data(const std::string &s): bookNo(s) { }
Sales_data(const std::string &s, unsigned n, double p):
bookNo(s), units_sold(n), revenue(p*n) { }
Sales_data(std::istream &);
// другие члены, как прежде
std::string isbn() const { return bookNo; }
Sales_data& combine(const Sales_data&);
double avg_price() const;
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};
Что значит = default
Начнем с объяснения стандартного конструктора:
Sales_data() = default;
В первую очередь обратите внимание на то, что это определение стандартного конструктора, поскольку он не получает никаких аргументов. Мы определяем этот конструктор только потому, что хотим предоставить другие конструкторы, но и стандартный конструктор тоже нужен. Этот конструктор должен делать то же, что и синтезируемая версия.
По новому стандарту, если необходимо стандартное поведение, можно попросить компилятор создать конструктор автоматически, указав после списка параметров часть = default. Синтаксис = default может присутствовать как в объявлении в теле класса, так и в определении вне его. Подобно любой другой функции, если часть = default присутствует в теле класса, стандартный конструктор будет встраиваемым; если она присутствует в определении вне класса, то по умолчанию этот член не будет встраиваемым.
Стандартный конструктор работает в классе Sales_data только потому, что предоставлены инициализаторы для переменных-членов встроенного типа. Если ваш компилятор не поддерживает внутриклассовые инициализаторы, для инициализации каждого члена класса стандартный конструктор должен использовать список инициализации конструктора (описанный непосредственно ниже).
Список инициализации конструктора
Теперь рассмотрим два других конструктора, которые были определены в классе:
Sales_data(const std::string &s) : bookNo(s) { }
Sales_data(const std::string &s, unsigned n, double p):
bookNo(s), units_sold(n), revenue(p*n) { }
Новой частью этих определений являются двоеточие и код между ним и фигурными скобками, обозначающими пустые тела функции. Эта новая часть — список инициализации конструктора (constructor initializer list), определяющий исходные значения для одной или нескольких переменных-членов создаваемого объекта. Инициализатор конструктора — это список имен переменных-членов класса, каждое из которых сопровождается исходным значением в круглых (или фигурных) скобках. Если инициализаций несколько, они отделяются запятыми.
Конструктор с тремя параметрами использует первые два параметра для инициализации переменных-членов bookNo и units_sold. Инициализатор для переменной revenue вычисляется при умножении количества проданных книг на их цену.
Конструктор с одним параметром типа string использует ее для инициализации переменной-члена bookNo, но переменные units_sold и revenue не инициализируются явно. Когда член класса отсутствует в списке инициализации конструктора, он инициализируется неявно, с использованием того же процесса, что и у синтезируемого стандартного конструктора. В данном случае эти члены инициализируются внутриклассовыми инициализаторами. Таким образом, получающий строку конструктор эквивалентен следующему.
// то же поведение, что и у исходного конструктора выше
Sales_data(const std::string &s):
bookNo(s), units_sold(0), revenue(0) { }
Обычно для конструктора лучше использовать внутриклассовый инициализатор, если он есть и присваивает члену класса правильное значение. С другой стороны, если ваш компилятор еще не поддерживает внутриклассовые инициализаторы, то каждый конструктор должен явно инициализировать каждый член встроенного типа.
Конструкторы не должны переопределять внутриклассовые инициализаторы, кроме как при использовании иного исходного значения. Если вы не можете использовать внутриклассовые инициализаторы, каждый конструктор должен явно инициализировать каждый член встроенного типа.
Следует заметить, что у обоих этих конструкторов тела пусты. Единственное, что должны сделать эти конструкторы, — присвоить значения переменным-членам. Если ничего другого делать не нужно, то тело функции пусто.
Определение конструктора вне тела класса
В отличие от наших других конструкторов, конструктору, получающему поток istream, действительно есть что делать. В своем теле этот конструктор вызывает функцию read(), чтобы присвоить переменным-членам новые значения:
Sales_data::Sales_data(std::istream &is) {
read(is, *this); // read читает транзакцию из is в текущий объект
}
У конструкторов нет типа возвращаемого значения, поэтому определение начинается с имени функции. Подобно любой другой функции-члену, при определении конструктора за пределами тела класса необходимо указать класс, которому принадлежит конструктор. Таким образом, синтаксис Sales data::Sales_data указывает, что мы определяем член класса Sales_data по имени Sales_data. Этот член класса является конструктором, поскольку его имя совпадает с именем класса.
В этом конструкторе нет списка инициализации конструктора, хотя с технической точки зрения было бы правильней сказать, что список инициализации конструктора пуст. Даже при том, что список инициализации конструктора пуст, члены этого объекта инициализируются прежде, чем выполняется тело конструктора.
Члены, отсутствующие в списке инициализации конструктора, инициализируются соответствующим внутриклассовым инициализатором (если он есть) или значением по умолчанию. Для класса Sales_data это означает, что при запуске тела функции на выполнение переменная bookNo будет содержать пустую строку, а переменные units_sold и revenue — значение 0.
Чтобы стало понятней, напомним, что второй параметр функции read() является ссылкой на объект класса Sales_data. В разделе 7.1.2 мы обращали внимание на то, что указатель this используется для доступа к объекту в целом, а не к его отдельному члену. В данном случае для передачи "этого" объекта в качестве аргумента функции read() используется синтаксис *this.
Упражнения раздела 7.1.4
Упражнение 7.11. Добавьте в класс Sales_data конструкторы и напишите программу, использующую каждый из них.
Упражнение 7.12. Переместите определение конструктора Sales_data(), получающего объект istream, в тело класса Sales_data.
Упражнение 7.13. Перепишите программу из раздела 7.1.1 так, чтобы использовать конструктор с параметром istream.
Упражнение 7.14. Напишите версию стандартного конструктора, явно инициализирующую переменные-члены значениями, предоставленными внутриклассовыми инициализаторами.
Упражнение 7.15. Добавьте соответствующие конструкторы в класс Person.
7.1.5. Копирование, присвоение и удаление
Кроме определения способа инициализации своих объектов, классы контролируют также то, что происходит при копировании, присвоении и удалении объектов класса. Объекты копируются во многих случаях: при инициализации переменной, при передаче или возвращении объекта по значению (см. раздел 6.2.1 и раздел 6.3.2). Объекты присваиваются при использовании оператора присвоения (см. раздел 4.4). Объекты удаляются, когда они прекращают существование, например, при выходе локального объекта из блока, в котором он был создан (см. раздел 6.1.1). Объекты, хранимые в векторе (или массиве), удаляются при удалении вектора (или массива).
Если мы не определим эти операции, компилятор создаст их сам. Обычно создаваемые компилятором версии выполняются, копируя, присваивая или удаляя каждую переменную-член объекта. Например, когда в приложении книжного магазина (см. раздел 7.1.1) компилятор выполняет следующее присвоение:
total = trans; // обработать следующую книгу
оно выполняется, как будто было написано так:
// присвоение по умолчанию для Sales_data эквивалентно следующему:
total.bookNo = trans.bookNo;
total.units_sold = trans.units_sold;
total.revenue = trans.revenue;
Более подробная информация об определении собственных версий этих операторов приведена в главе 13.
#magnify.png Некоторые классы не могут полагаться на синтезируемые версии
Хотя компилятор и создает сам операторы копирования, присвоения и удаления, важно понимать, что у некоторых классов их стандартные версии ведут себя неправильно. В частности, синтезируемые версии вряд ли будут правильно работать с классами, которые резервируют ресурсы, располагающиеся вне самих объектов класса. Пример резервирования и управления динамической памятью приведен в главе 12. Как будет продемонстрировано в разделе 13.6, классы, которые управляют динамической памятью, вообще не могут полагаться на синтезируемые версии этих операций.
Однако следует заметить, что большинство классов, нуждающихся в динамической памяти, способны (и должны) использовать классы vector или string, если им нужно управляемое хранение. Классы, использующие векторы и строки, избегают сложностей, связанных с резервированием и освобождением памяти.
Кроме того, синтезируемые версии операторов копирования, присвоения и удаления правильно работают для классов, у которых есть переменные-члены класса vector или string. При копировании или присвоении объекта, обладающего переменной-членом класса vector, этот класс сам позаботится о копировании и присвоении своих элементов. Когда объект удаляется, переменная-член класса vector тоже удаляется, что в свою очередь удаляет элементы вектора. Класс string работает аналогично.
Пока вы еще не знаете, как определить операторы, описанные в главе 13, ресурсы, резервируемые вашими классами, должны храниться непосредственно как переменные-члены класса.
7.2. Управление доступом и инкапсуляция
На настоящий момент для нашего класса определен интерфейс; однако ничто не вынуждает пользователей использовать его. Наш класс еще не использует инкапсуляцию — пользователи вполне могут обратиться к объекту Sales_data и воспользоваться его реализацией. Для обеспечения инкапсуляции в языке С++ используют спецификаторы доступа (access specifier).
• Члены класса, определенные после спецификатора public, доступны для всех частей программы. Открытые члены (public member) определяют интерфейс к классу.
• Члены, определенные после спецификатора private, являются закрытыми (private member), они доступны для функций-членов класса, но не доступны для кода, который использует класс. Разделы private инкапсулируют (т.е. скрывают) реализацию.
Переопределив класс Sales_data еще раз, получаем следующее:
class Sales_data {
public: // добавлен спецификатор доступа
Sales_data() = default;
Sales_data(const std::string &s, unsigned n, double p):
bookNo(s), units_sold(n), revenue(p*n) { }
Sales_data(const std::string &s): bookNo(s) { }
Sales_data(std::istream&);
std::string isbn() const { return bookNo; }
Sales_data &combine(const Sales_data&);
private: // добавлен спецификатор доступа
double avg_price() const
{ return units_sold ? revenue/units_sold : 0; }
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};
Конструкторы и функции-члены, являющиеся частью интерфейса (например, isbn() и combine()), должны располагаться за спецификатором public; переменные-члены и функции, являющиеся частью реализации, располагаются за спецификатором private.
Класс может содержать любое количество спецификаторов доступа; нет никаких ограничений на то, как часто используется спецификатор. Каждый спецификатор определяет уровень доступа последующих членов. Заданный уровень доступа остается в силе до следующего спецификатора доступа или до конца тела класса.
Использование ключевых слов class и struct
Было также внесено еще одно изменение: в начале определения класса использовано ключевое слово class, а не struct. Это изменение является чисто стилистическим; тип класса можно определить при помощи любого из этих ключевых слов. Единственное различие между ключевыми словами struct и class в заданном по умолчанию уровне доступа.
Члены класса могут быть определены перед первым спецификатором доступа. Уровень доступа к таким членам будет зависеть от того, как определяется класс. Если используется ключевое слово struct, то члены, определенные до первого спецификатора доступа, будут открытыми; если используется ключевое слово class, то они будут закрытыми.
Общепринятым стилем считается определение классов, все члены которого предположительно будут открытыми, с использованием ключевого слова struct. Если члены класса должны быть закрытыми, используется ключевое слово class.
Единственное различие между ключевыми словами class и struct в задаваемом по умолчанию уровне доступа.
Ключевая концепция. Преимущества инкапсуляции
Инкапсуляция предоставляет два важных преимущества.
• Пользовательский код не может по неосторожности повредить состояние инкапсулированного объекта.
• Реализация инкапсулированного класса может со временем измениться, это не потребует изменений в коде на пользовательском уровне.
Определив переменные-члены закрытыми, автор класса получает возможность вносить изменения в данные. Если реализация изменится, то вызванные этим последствия можно исследовать только в коде класса. Пользовательский код придется изменять только при изменении интерфейса. Если данные являются открытыми, то любой использовавший их код может быть нарушен. Пришлось бы найти и переписать любой код, который полагался на прежнюю реализацию, и только затем использовать программу.
Еще одно преимущество объявления переменных-членов закрытыми в том, что данные защищены от ошибок, которые могли бы внести пользователи. Если есть ошибка, повреждающая состояние объекта, места ее поиска ограничены только тем кодом, который является частью реализации. Это существенно облегчает поиск проблем и обслуживание программы.
Упражнения раздела 7.2
Упражнение 7.16. Каковы ограничения (если они есть) на количество спецификаторов доступа в определении класса? Какие виды членов должны быть определены после спецификатора public? Какие после спецификатора private?
Упражнение 7.17. Каковы различия (если они есть) между ключевыми словами class и struct?
Упражнение 7.18. Что такое инкапсуляция? Чем она полезна?
Упражнение 7.19. Укажите, какие члены класса Person имеет смысл объявить как public, а какие как private. Объясните свой выбор.
7.2.1. Друзья
Теперь, когда переменные-члены класса Sales_data стали закрытыми, функции read(), print() и add() перестали компилироваться. Проблема в том, что хоть эти функции и являются частью интерфейса класса Sales_data, его членами они не являются.
Класс может позволить другому классу или функции получить доступ к своим не открытым членам, установив для них дружественные отношения (friend). Класс объявляет функцию дружественной, включив ее объявление с предваряющим ключевым словом friend:
class Sales_data {
// добавлены объявления дружественных функций, не являющихся
// членами класса Sales_data
friend Sales_data add(const Sales_data&, const Sales_data&);
friend std::istream &read(std::istream&, Sales_data&);
friend std::ostream &print(std::ostream&, const Sales_data&);
// другие члены и спецификаторы доступа, как прежде
public:
Sales_data() = default;
Sales data(const std::string &s, unsigned n, double p):
bookNo(s), units_sold(n), revenue (p*n) { }
Sales_data(const std::string &s): bookNo(s) { }
Sales_data(std::istream&);
std::string isbn() const { return bookNo; }
Sales_data &combine(const Sales data&);
private:
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};
// объявления частей, не являющихся членами интерфейса
// класса Sales_data
Sales_data add(const Sales_data&, const Sales_data&);
std::istream &read(std::istream&, Sales_data&);
std::ostream &print(std::ostream&, const Sales_data&);
Объявления друзей могут располагаться только в определении класса; использоваться они могут в классе повсюду. Друзья не являются членами класса и не подчиняются спецификаторам доступа раздела, в котором они объявлены. Более подробная информация о дружественных отношениях приведена в разделе 7.3.4.
Объявления друзей имеет смысл группировать в начале или в конце определения класса.
Хотя пользовательский код не должен изменяться при изменении определения класса, файлы исходного кода, использующие этот класс, следует перекомпилировать при каждом изменении класса.
#reader.png Объявление дружественных отношений
Объявление дружественных отношений устанавливает только право доступа. Это не объявление функции. Если необходимо, чтобы пользователи класса были в состоянии вызвать дружественную функцию, ее следует также объявить.
Чтобы сделать друзей класса видимыми его пользователям, их обычно объявляют вне класса в том же заголовке, что и сам класс. Таким образом, в заголовке Sales_data следует предоставить отдельные объявления (кроме объявлений дружественными в теле класса) для функций read(), print() и add().
Многие компиляторы не выполняют правило, согласно которому дружественные функции должны быть объявлены вне класса, прежде чем они будут применены.
Некоторые компиляторы позволяют вызвать дружественную функцию, когда для нее нет обычного объявления. Даже если ваш компилятор позволяет такие вызовы, имеет смысл предоставлять отдельные объявления для дружественных функций. Так не придется переделывать весь код, если вы перейдете на компилятор, который выполняет это правило.
Упражнения раздела 7.2.1
Упражнение 7.20. Когда полезны дружественные отношения? Укажите преимущества и недостатки их использования.
Упражнение 7.21. Измените свой класс Sales_data так, чтобы скрыть его реализацию. Написанные вами программы, которые использовали операции класса Sales_data, должны продолжить работать. Перекомпилируйте эти программы с новым определением класса, чтобы проверить, остались ли они работоспособными.
Упражнение 7.22. Измените свой класс Person так, чтобы скрыть его реализацию.
7.3. Дополнительные средства класса
Хотя класс Sales_data довольно прост, он все же позволил исследовать немало средств поддержки классов. В этом разделе рассматриваются некоторые из дополнительных средств, связанных с классом, которые класс Sales_data не будет использовать. К этим средствам относятся типы-члены (type member), внутриклассовые инициализаторы для типов-членов класса, изменяемые переменные-члены, встраиваемые функции-члены, функции-члены, возвращающие *this, а также подробности определения и использования типов класса и дружественных классов.
7.3.1. Снова о членах класса
Для исследования некоторых из дополнительных средств определим пару взаимодействующих классов по имени Screen и Window_mgr.
Определение типов-членов
Класс Screen представляет окно на экране. У каждого объекта класса Screen есть переменная-член типа string, хранящая содержимое окна и три переменные-члена типа string::size_type, представляющие позицию курсора, высоту и ширину окна.
Кроме переменных и функций-членов, класс может определять собственные локальные имена таких типов. Определенные классом имена типов подчиняются тем же правилам доступа, что и любой другой его член, и могут быть открытыми или закрытыми:
class Screen {
public:
typedef std::string::size_type pos;
private:
pos cursor = 0;
pos height = 0, width = 0;
std::string contents;
};
Тип pos определен в части public класса Screen, поскольку пользователи должны использовать это имя. Пользователи класса Screen не обязаны знать, что он использует класс string для хранения своих данных. Определив тип pos как открытый член, эту подробность реализации класса Screen можно скрыть.
В объявлении типа pos есть два интересных момента. Во-первых, хоть здесь и был использован оператор typedef (см. раздел 2.5.1), с таким же успехом можно использовать псевдоним типа (см. раздел 2.5.1):
class Screen {
public:
// альтернативный способ объявления типа-члена с использованием
// псевдонима типа
using pos = std::string::size_type;
// другие члены как прежде
};
Во-вторых, по причинам, которые будут описаны в разделе 7.3.4, в отличие от обычных членов, типы-члены определяются прежде, чем используются. В результате типы-члены обычно располагают в начале класса.
Функции-члены класса Screen
Чтобы сделать наш класс полезней, добавим в него конструктор, позволяющий пользователям задавать размер и содержимое экрана, наряду с членами, позволяющими переместить курсор и получить символ в указанной позиции:
class Screen {
public:
typedef std::string::size_type pos;
Screen() = default; // необходим, поскольку у класса Screen есть
// другой конструктор
// внутриклассовый инициализатор инициализирует курсор значением 0
Screen(pos ht, pos wd, char c) : height(ht), width(wd),
contents(ht * wd, c) { }
char get() const // получить символ в курсоре
{ return contents [cursor]; } // неявно встраиваемая
inline char get(pos ht, pos wd) const; // явно встраиваемая
Screen &move(pos r, pos с); // может быть сделана встраиваемой позже
private:
pos cursor = 0;
pos height = 0, width = 0;
std::string contents;
};
Поскольку мы предоставляем конструктор, компилятор не будет автоматически создавать стандартный конструктор сам. Если у нашего класса должен быть стандартный конструктор, то придется создать его явно. В данном случае используется синтаксис = default, чтобы попросить компилятор самому создать определение стандартного конструктора (см. раздел 7.1.4).
Стоит также обратить внимание на то, что второй конструктор (получающий три аргумента) неявно использует внутриклассовый инициализатор для переменной-члена cursor (см. раздел 7.1.4). Если бы у класса не было внутриклассового инициализатора для переменной-члена cursor, то мы явно инициализировали бы ее наряду с другими переменными-членами.
Встраиваемые члены класса
У классов зачастую бывают небольшие функции, которые выгодно сделать встраиваемыми. Как уже упоминалось, определенные в классе функции-члены автоматически являются встраиваемыми (inline) (см. раздел 6.5.2). Таким образом, конструкторы класса Screen и версия функции get(), возвращающей обозначенный курсором символ, являются встраиваемыми по умолчанию.
Функцию-член можно объявить встраиваемой явно в ее объявлении в теле класса. В качестве альтернативы функцию можно указать встраиваемой в определении, расположенном вне тела класса:
inline // функцию можно указать встраиваемой в определении
Screen &Screen::move(pos r, pos с) {
pos row = r * width; // вычислить положение ряда
cursor = row + с; // переместить курсор к столбцу этого ряда
return *this; // возвратить этот объект как l-значение
}
char Screen::get(pos r, pos с) const // объявить встраиваемый в классе
{
pos row = r * width; // вычислить положение ряда
return contents[row + с]; // возвратить символ в данном столбце
}
Хоть и не обязательно делать это, вполне допустимо указать ключевое слово inline и в объявлении, и в определении. Однако указание ключевого слова inline в определении только вне класса может облегчить чтение класса.
По тем же причинам, по которым встраиваемые функции определяют в заголовках (см. раздел 6.5.2), встраиваемые функции-члены следует определить в том же заголовке, что и определение соответствующего класса.
Перегрузка функций-членов
Подобно функциям, которые не являются членами класса, функции-члены могут быть перегружены (см. раздел 6.4), если они отличаются количеством и/или типами параметров. При вызове функции-члена используется тот же процесс подбора функции (см. раздел 6.4), что и у функций, не являющихся членом класса.
Например, в классе Screen определены две версии функции get(). Одна версия возвращает символ, обозначенный в настоящее время курсором; другая возвращает символ в указанной позиции, определенной ее рядом и столбцом. Чтобы определить применяемую версию, компилятор использует количество аргументов:
Screen myscreen;
char ch = myscreen.get(); // вызов Screen::get()
ch = myscreen.get(0,0); // вызов Screen::get(pos, pos)
Изменяемые переменные-члены
Иногда (но не очень часто) у класса есть переменная-член, которую следует сделать изменяемой даже в константной функции-члене. Для обозначения таких членов в их объявление включают ключевое слово mutable.
Изменяемая переменная-член (mutable data member) никогда не бывает константой, даже когда это член константного объекта. Соответственно константная функция-член может изменить изменяемую переменную-член. В качестве примера добавим в класс Screen изменяемую переменную-член access_ctr, используемую для отслеживания частоты вызова каждой функции-члена класса Screen:
class Screen {
public:
void some_member() const;
private:
mutable size_t access_ctr; // может измениться даже в константном
// объекте
// другие члены как прежде
};
void Screen::some_member() const {
++access_ctr; // сохранить количество вызовов любой функции-члена
// безотносительно других выполняемых ею действий
}
Несмотря на то что функция-член some_member() константная, она может изменить значение переменной-члена access_сtr. Этот член класса является изменяемым, поэтому любая функция-член, включая константные, может изменить это значение.
Инициализаторы переменных-членов класса
Кроме класса Screen, определим также класс диспетчера окон, который представляет коллекцию окон на данном экране. У этого класса будет вектор объектов класса Screen, каждый элемент которого представляет отдельное окно. По умолчанию класс Window_mgr должен изначально содержать один объект класса Screen, инициализированный значением по умолчанию. По новому стандарту наилучшим способом определения такого значения по умолчанию является внутриклассовый инициализатор (см. раздел 2.6.1):
class Window_mgr {
private:
// по умолчанию отслеживающий окна объект класса Window_mgr
// содержит одно пустое окно стандартного размера
std::vector
};
При инициализации переменных-членов типа класса их конструктору следует предоставить аргументы. В этом случае применяется список инициализации переменной-члена типа vector (см. раздел 3.3.1) с инициализатором для одного элемента. Этот инициализатор содержит значение типа Screen, передаваемое конструктору vector
Как уже упоминалось, для внутриклассовой инициализации может использоваться форма инициализации = (как при инициализации переменных-членов класса Screen) или прямая форма инициализации с использованием фигурных скобок (как у вектора screens).
При предоставлении внутриклассового инициализатора это следует сделать после знака = или в фигурных скобках.
Упражнения раздела 7.3.1
Упражнение 7.23. Напишите собственную версию класса Screen.
Упражнение 7.24. Добавьте в свой класс Screen три конструктора: стандартный; получающий высоту, ширину и заполняющий содержимое соответствующим количеством пробелов; получающий высоту, ширину и заполняющий символ для содержимого экрана.
Упражнение 7.25. Может ли класс Screen безопасно полагаться на заданные по умолчанию версии операторов копирования и присвоения? Если да, то почему? Если нет, то почему?
Упражнение 7.26. Определите функцию Sales data::avg_price как встраиваемую.
7.3.2. Функции, возвращающие указатель
*this
Теперь добавим функции, устанавливающие символ в курсоре или в заданной области:
class Screen {
public:
Screen &set(char);
Screen &set(pos, pos, char);
// другие члены, как прежде
};
inline Screen &Screen::set(char c) {
contents[cursor] = с; // установите новое значение в текущей позиции
// курсора
return *this; // возвратить этот объект как l-значение
}
inline Screen &Screen::set(pos r, pos col, char ch) {
contents[r * width + col] = ch; // установить позицию по данному
// значению
return *this; // возвратить этот объект как l-значение
}
Как и функция move(), функция-член set() возвращает ссылку на объект, из которого они вызваны (см. раздел 7.1.2). Возвращающие ссылку функции являются l-значениями (см. раздел 6.3.2), а это означает, что они возвращают сам объект, а не его копию. Это позволяет связать несколько их вызовов в одно выражение:
// переместить курсор в указанную позицию и присвоить
// символу значение
myScreen.move(4,0).set('#');
Эти операции выполнятся для того же объекта. В этом выражении сначала перемещается курсор (move()) в окно (myScreen), а затем устанавливается (set()) заданный символ. Таким образом, этот оператор эквивалентен следующему:
myScreen.move(4,0);
myScreen.set('#');
Если бы функции move() и set() возвращали тип Screen, а не Screen&, этот оператор выполнялся бы совсем по-другому. В данном случае он был бы эквивалентен следующему:
// если move возвращает Screen, а не Screen&
Screen temp = myScreen.move(4,0); // возвращаемое значение было
// бы скопировано
temp.set('#'); // содержимое myScreen осталось бы неизменно
Если бы функция move() имела возвращаемое значение не ссылочного типа, то оно было бы копией *this (см. раздел 6.3.2). Вызов функции set() изменил бы лишь временную копию, а не сам объект myScreen.
Возвращение *this из константной функции-члена
Теперь добавим функцию display(), выводящую содержимое окна. Необходима возможность включать эту операцию в последовательность операций set() и move(). Поэтому, подобно функциям set() и move(), функция display() возвратит ссылку на объект, для которого она выполняется.
Логически отображение объекта класса Screen (окна) не изменяет его, поэтому функцию display() следует сделать константным членом. Но если функция display() будет константной, то this будет указателем на константу, а значение *this — константным объектом. Следовательно, типом возвращаемого значения функции display() будет const Screen&. Однако, если функция display() возвратит ссылку на константу, мы не сможем вставить вызов функции display() в последовательность действий:
Screen myScreen;
// если display возвращает константную ссылку,
// вызов в последовательности будет ошибкой
myScreen.display(cout).set('*');
Хотя объект myScreen неконстантный, вызов функции set() не будет компилироваться. Проблема в том, что константная версия функции display() возвращает ссылку на константу, и мы не можем вызвать функцию set() для константного объекта.
Тип возвращаемого значения константной функции-члена, возвращающей *this как ссылку, должен быть ссылкой на константу.
Перегрузка на основании константности
Функции-члены вполне можно перегружать исходя из того, являются ли они константными или нет, причем по тем же причинам, по которым функцию можно перегружать исходя из того, является ли ее параметр указателем на константу (см. раздел 6.4). Неконстантная версия неприменима для константных объектов; она применима только для константных объектов. Для неконстантного объекта можно вызвать любую версию, но неконстантная версия будет лучшим соответствием.
В этом примере определим закрытую функцию-член do_display() для фактического вывода окна. Каждая из функций display() вызовет эту функцию, а затем возвратит объект, для которого она выполняется:
class Screen {
public:
// display перегружена на основании того, является ли
// объект константой или нет
Screen &display(std::ostream &os)
{ do_display(os); return *this; }
const Screen &display(std::ostream &os) const
{ do_display(os); return *this; }
private:
// функция отображения окна
void do_display(std::ostream &os) const {os << contents;}
// другие члены как прежде
};
Как и в любом другом случае, при вызове одной функции-члена другой неявно передается указатель this. Таким образом, когда функция display() вызывает функцию-член do_display(), ей неявно передается собственный указатель this. Когда неконстантная версия функции display() вызывает функцию do_display(), ее указатель this неявно преобразуется из указателя на неконстанту в указатель на константу (см. раздел 4.11.2).
Когда функция do_display() завершает работу, функция display() возвращает объект, с которым они работают, обращаясь к значению указателя this. В неконстантной версии указатель this указывает на неконстантный объект, так что эта версия функции display() возвращает обычную, неконстантную ссылку; константная версия возвращает ссылку на константу.
Когда происходит вызов функции display() для объекта, вызываемую версию определяет его константность:
Screen myScreen(5, 3);
const Screen blank(5, 3);
myScreen.set('#').display(cout); // вызов неконстантной версии
blank.display(cout); // вызов константной версии
Совет. Используйте закрытые вспомогательные функции
Некоторые читатели могут удивиться: зачем дополнительно создавать отдельную функцию do_display() ? В конце концов, обращение к функции do_display() не намного проще, чем осуществляемое в ней действие.
Зачем же она нужна? Причин здесь несколько.
• Всегда желательно избегать нескольких экземпляров одного кода.
• По мере развития класса функция display() может стать значительно более сложной, а следовательно, преимущества одной, а не нескольких копий кода станут более очевидными.
• Во время разработки в тело функции display() , вероятно, придется добавить отладочный код, который в финальной версии будет удален. Это будет проще сделать в случае, когда весь отладочный код находится в одной функции do_display() .
• Поскольку функция do_display() объявлена встраиваемой ( inline ), при создании исполняемого кода компилятор и так вставит ее содержимое по месту вызова, поэтому вызов функции не повлечет за собой никаких потерь времени и ресурсов.
Обычно в хорошо спроектированных программах на языке С++ присутствует множество маленьких функций, таких как do_display() , которые выполняют всю основную работу, когда их использует набор других функций.
Упражнения раздела 7.3.2
Упражнение 7.27. Добавьте функции move(), set() и display() в свою версию класса Screen. Проверьте свой класс, выполнив следующий код:
Screen myScreen(5, 5, 'X');
myScreen.move(4,0).set('#').display(cout);
cout << "\n";
myScreen.display(cout);
cout << "\n";
Упражнение 7.28. Что если бы в предыдущем упражнении типом возвращаемого значения функций move(), set() и display() был Screen, а не Screen&?
Упражнение 7.29. Пересмотрите свой класс Screen так, чтобы функции move(), set() и display() возвращали тип Screen, а затем проверьте свое предположение из предыдущего упражнения.
Упражнение 7.30. Обращение к членам класса при помощи указателя this вполне допустимо, но избыточно. Обсудите преимущества и недостатки явного использования указателя this для доступа к членам.
7.3.3. Типы классов
Каждый класс определяет уникальный тип. Два различных класса определяют два разных типа, даже если их члены совпадают. Например:
struct First {
int memi;
int getMem();
};
struct Second {
int memi;
int getMem();
};
First obj1;
Second obj2 = obj1; // ошибка: obj1 и obj2 имеют разные типы
Даже если у двух классов полностью совпадает список членов, они являются разными типами. Члены каждого класса отличны от членов любого другого класса (или любой другой области видимости).
К типу класса можно обратиться непосредственно, используя имя класса как имя типа. В качестве альтернативы можно использовать имя класса после ключевого слова class или struct:
Sales_data item1; // инициализация значением по умолчанию объекта
// типа Sales_data
class Sales_data item1; // эквивалентное объявление
Оба способа обращения к типу класса эквивалентны. Второй метод унаследован от языка С и все еще допустим в С++.
Объявления класса
Подобно тому, как можно объявить функцию без ее определения (см. раздел 6.1.2), можно объявить (class declaration) класс, не определяя его:
class Screen; // объявление класса Screen
Такое объявление иногда называют предварительным объявлением (forward declaration), оно вводит имя Screen в программу и указывает, что оно относится к типу класса. После объявления, но до определения, тип Screen считается незавершенным типом (incomplete type), т.е. известно, что Screen — это тип класса, но не известно, какие члены он содержит.
Использование незавершенного типа весьма ограниченно. Его можно использовать только для определения указателей или ссылок, а также для объявления (но не определения) функций, которые используют этот тип как параметр или тип возвращаемого значения.
Прежде чем можно будет писать код, создающий объекты некого класса, его следует определить, а не только объявить. В противном случае компилятор не будет знать, в каком объеме памяти нуждаются его объекты. Аналогично класс должен быть уже определен перед использованием ссылки или указателя для доступа к члену класса. В конце концов, если класс не был определен, компилятор не сможет узнать, какие члены имеет класс.
За одним исключением, рассматриваемым в разделе 7.6, переменные-члены могут быть определены как имеющие тип класса, только если класс был определен. Тип следует завершить, поскольку компилятор должен знать объем памяти, необходимый для хранения переменных-членов. Пока класс не определен, пока его тело не создано, у класса не может быть переменных-членов его собственного типа. Однако класс считается объявленным (но еще не определенным), как только его имя стало видимо. Поэтому у класса могут быть переменные-члены, являющиеся указателями или ссылками на ее собственный тип:
class Link_screen {
Screen window;
Link_screen *next;
Link_screen *prev;
};
Упражнения раздела 7.3.3
Упражнение 7.31. Определите два класса, X и Y, у которых класс X имеет указатель на класс Y, a Y содержит объект типа X.
7.3.4. Снова о дружественных отношениях
Наш класс Sales_data определил три обычных функции, не являющиеся членом класса, как дружественные (см. раздел 7.2.1). Класс может также сделать дружественным другой класс или объявить дружественными определенные функции-члены другого (определенного ранее) класса. Кроме того, дружественная функция может быть определена в теле класса. Такие функции неявно являются встраиваемыми.
Дружественные отношения между классами
В качестве примера дружественных классов рассмотрим класс Window_mgr (см. раздел 7.3.1), его членам понадобится доступ к внутренним данным объектов класса Screen, которыми они управляют. Предположим, например, что в класс Window_mgr необходимо добавить функцию-член clear(), заполняющую содержимое определенного окна пробелами. Для этого функции clear() нужен доступ к закрытым переменным-членам класса Screen. Для этого класс Screen должен объявить класс Window_mgr дружественным:
class Screen {
// члены класса Window_Mgr смогут обращаться к закрытым
// членам класса Screen
friend class Window_mgr;
// ... остальное, как раньше в классе Screen
};
Функции-члены дружественного класса могут обращаться ко всем членам класса, объявившего его другом, включая не открытые члены. Теперь, когда класс Window_mgr является другом класса Screen, функцию-член clear() класса Window_mgr можно переписать следующим образом:
class Window_mgr {
public:
// идентификатор области для каждого окна на экране
using ScreenIndex = std::vector
// сбросить данное окно, заполнив его пробелами
void clear(ScreenIndex);
private:
std::vector
};
void Window_mgr::clear(ScreenIndex i) {
// s - ссылка на окно, которое предстоит очистить
Screen &s = screens[i];
// сбросить данное окно, заполнив его пробелами
s.contents = string(s.height * s.width, ' ');
}
Сначала определим s как ссылку на класс Screen в позиции i вектора окон. Затем переменные-члены height и width данного объекта класса Screen используются для вычисления количества символов новой строки, содержащей пробелы. Эта заполненная пробелами строка присваивается переменной-члену contents.
Если бы функция clear() не была дружественной классу Screen, то этот код не компилировался бы. Функция clear() не смогла бы использовать переменные-члены height, width или contents класса Screen. Поскольку класс Screen установил дружественные отношения с классом Window_mgr, для его функций доступны все члены класса Screen.
Важно понимать, что дружественные отношения не передаются. Таким образом, если у класса Window_mgr есть собственные друзья, то у них нет привилегий доступа к членам класса Screen.
Каждый класс сам контролирует, какие классы или функции будут его друзьями.
Как сделать функцию-член дружественной
Вместо того чтобы делать весь класс Window_mgr дружественным классу Screen, можно предоставить доступ только функции-члену clear(). Когда функция-член объявляется дружественной, следует указать класс, которому она принадлежит:
class Screen {
// класс Window_mgr::clear должен быть объявлен перед классом Screen
friend void Window_mgr::clear(ScreenIndex);
// ... остальное как раньше в классе Screen
};
Создание дружественных функций-членов требует тщательного структурирования программ в соответствии с взаимозависимостями объявлений и определений. В данном случае программу следует упорядочить следующим образом.
• Сначала определите класс Window_mgr, который объявляет, но не может определить функцию clear(). Класс Screen должен быть объявлен до того, как функция clear() сможет использовать члены класса Screen.
• Затем определите класс Screen, включая объявление функции clear() дружественной.
• И наконец, определите функцию clear(), способную теперь обращаться к членам класса Screen.
Перегруженные функции и дружественные отношения
Хотя у перегруженных функций одинаковое имя, это все же разные функции. Поэтому класс должен объявить дружественной каждую из перегруженных функций:
// перегруженные функции storeOn
extern std::ostream& storeOn(std::ostream &, Screen &);
extern BitMap& storeOn(BitMap &, Screen &);
class Screen {
// версия ostream функции storeOn может обращаться к закрытым членам
// объектов класса Screen
friend std::ostream& storeOn(std::ostream &, Screen &); // ...
};
Класс Screen объявляет другом версию функции storeOn, получающей поток ostream&. Версия, получающая параметр BitMap&, особых прав доступа к объектам класса Screen не имеет.
#magnify.png Объявление дружественных отношений и область видимости
Классы и функции, не являющиеся членами класса, не следует объявлять прежде, чем они будут использованы в объявлении дружественными. Когда имя впервые появляется в объявлении дружественной, оно неявно подразумевается принадлежащей окружающей области видимости. Однако сам друг фактически не объявлен в этой области видимости (см. раздел 7.2.1).
Даже если мы определим функцию в классе, ее все равно придется объявить за пределами класса, чтобы сделать видимой. Объявление должно уже существовать, даже если вызывается дружественная функция:
struct X {
friend void f() { /* дружественная функция может быть определена
в теле класса */ }
X() { f(); } // ошибка: нет объявления для f
void g();
void h();
};
void X::g() { return f(); } // ошибка: f не была объявлена
void f(); // объявляет функцию, определенную в X
void X::h() { return f(); } // ok: объявление f теперь в области
// видимости
Важно понимать, что объявление дружественной затрагивает доступ, но не является объявлением в обычном смысле.
Помните: некоторые компиляторы не выполняют правил поиска имен друзей (см. раздел 7.2.1).
Упражнения раздела 7.3.4
Упражнение 7.32. Определите собственные версии классов Screen и Window_mgr, в которых функция clear() является членом класса Window_mgr и другом класса Screen.
7.4. Область видимости класса
Каждый класс определяет собственную область видимости. Вне области видимости класса (class scope) к обычным данным и функциям его члены могут обращаться только через объект, ссылку или указатель, используя оператор доступа к члену (см. раздел 4.6). Для доступа к членам типа из класса используется оператор области видимости. В любом случае следующее за оператором имя должно быть членом соответствующего класса.
Screen::pos ht = 24, wd = 80; // использование типа pos, определенного
// в классе Screen
Screen scr(ht, wd, ' ');
Screen *p = &scr;
char c = scr.get(); // доступ к члену get() объекта
scr c = p->get(); // доступ к члену get() из объекта, на который
// указывает p
Область видимости и члены, определенные вне класса
Тот факт, что класс определяет область видимости, объясняет, почему следует предоставить имя класса наравне с именем функции, при определении функции-члена вне ее класса (см. раздел 7.1.2). За пределами класса имена ее членов скрыты.
Как только имя класса становится видимо, остальная часть определения, включая список параметров и тело функции, находится в области видимости класса. В результате мы можем обращаться к другим членам класса без уточнения.
Вернемся, например, к функции-члену clear() класса Window_mgr (см. раздел 7.3.4). Параметр этой функции имеет тип, определенный в классе Window_mgr:
void Window_mgr::clear(ScreenIndex i) {
Screen &s = screens[i];
s.contents = string(s.height * s.width, ' ');
}
Поскольку компилятор видит последующий список параметров и ничего подобного в области видимости класса WindowMgr, нет никакой необходимости определять, что требуется тип ScreenIndex, определенный в классе WindowMgr. По той же причине использование объекта screens в теле функции относится к имени, объявленному в классе Window_mgr.
С другой стороны, тип возвращаемого значения функции обычно располагается перед именем функции. Когда функция-член определяется вне тела класса, любое имя, используемое в типе возвращаемого значения, находится вне области видимости класса. В результате тип возвращаемого значения должен определять класс, членом которого он является. Например, мы могли бы добавить в класс Window_mgr функцию addScreen(), добавляющую еще одно окно на экран. Этот член класса возвратит значение типа ScreenIndex, которое пользователь впоследствии сможет использовать для поиска этого окна:
class Window_mgr {
public:
// добавить окно на экран и возвратить его индекс
ScreenIndex addScreen(const Screen&);
// другие члены, как прежде
};
// тип возвращаемого значения видим прежде, чем начинается область
// видимости класса Window_mgr
Window_mgr::ScreenIndex
Window_mgr::addScreen(const Screen &s) {
screens.push_back(s);
return screens.size() - 1;
}
Поскольку тип возвращаемого значения встречается прежде имени класса, оно находится вне области видимости класса Window_mgr. Чтобы использовать тип ScreenIndex для возвращаемого значения, следует определить класс, в котором определяется этот тип.
Упражнения раздела 7.4
Упражнение 7.33. Что будет, если добавить в класс Screen переменную-член size(), определенную следующим образом? Исправьте все обнаруженные ошибки.
pos Screen::size() const {
return height * width;
}
7.4.1. Поиск имен в области видимости класса
В рассмотренных до сих пор программах поиск имен (name lookup) (процесс поиска объявления, соответствующего данному имени) был относительно прост.
• Сначала поиск объявления осуществляется в том блоке кода, в котором используется имя. Причем рассматриваются только те имена, объявления которых расположены перед местом применения.
• Если имя не найдено, поиск продолжается в иерархии областей видимости, начиная с текущей.
• Если объявление так и не найдено, происходит ошибка.
Когда поиск имен осуществляется в функциях-членах, определенных в классе, может показаться, что он происходит не по правилам поиска. Но в данном случае внешний вид обманчив. Обработка определений классов осуществляется в два этапа.
• Сначала компилируются объявления членов класса.
• Тела функции компилируются только после того, как виден весь класс.
Определения функций-членов обрабатываются после того, как компилятор обработает все объявления в классе.
Классы обрабатываются в два этапа, чтобы облегчить организацию кода класса. Поскольку тела функций-членов не обрабатываются, пока весь класс не станет видимым, они смогут использовать любое имя, определенное в классе. Если бы определения функций обрабатывались одновременно с объявлениями переменных-членов, то пришлось бы располагать функции-члены так, чтобы они обращались только к тем именам, которые уже видимы.
Поиск имен для объявлений членов класса
Этот двухэтапный процесс применяется только к именам, используемым в теле функции-члена. Имена, используемые в объявлениях, включая имя типа возвращаемого значения и типов списка параметров, должны стать видимы прежде, чем они будут использованы. Если объявление переменной-члена будет использовать имя, объявление которого еще не видимо в классе, то компилятор будет искать то имя в той области (областях) видимости, в которой определяется класс. Рассмотрим пример.
typedef double Money;
string bal;
class Account {
public:
Money balance() { return bal; }
private:
Money bal;
// ...
};
Когда компилятор видит объявление функции balance(), он ищет объявление имени Money в классе Account. Компилятор рассматривает только те объявления в классе Account, которые расположены перед использованием имени Money. Поскольку его объявление как члена класса не найдено, компилятор ищет имя в окружающей области видимости. В этом примере компилятор найдет определение типа (typedef) Money. Этот тип будет использоваться и для типа возвращаемого значения функции balance(), и как тип переменной-члена bal. С другой стороны, тело функции balance() обрабатывается только после того, как видимым становится весь класс. Таким образом, оператор return в этой функции возвращает переменную-член по имени bal, а не строку из внешней области видимости.
Имена типов имеют особенности
Обычно внутренняя область видимости может переопределить имя из внешней области видимости, даже если это имя уже использовалось во внутренней области видимости. Но если член класса использует имя из внешней области видимости и это имя типа, то класс не сможет впоследствии переопределить это имя:
typedef double Money;
class Account {
public:
Money balance() { return bal; } // используется имя Money из внешней
// область видимости
private:
typedef double Money; // ошибка: нельзя переопределить Money
Money bal;
// ...
};
Следует заметить, что хотя определение типа Money в классе Account использует тот же тип, что и определение во внешней области видимости, этот код все же ошибочен.
Хотя переопределение имени типа является ошибкой, не все компиляторы обнаружат эту ошибку. Некоторые спокойно примут такой код, даже если программа ошибочна.
Определения имен типов обычно располагаются в начале класса. Так, любой член класса, который использует этот тип, будет расположен после определения его имени.
При поиске имен в областях видимости члены класса следуют обычным правилам
Поиск имени, используемого в теле функции-члена, осуществляется следующим образом.
• Сначала поиск объявления имени осуществляется в функции-члене. Как обычно, рассматриваются объявления в теле функции, только предшествующие месту использования имени.
• Если в функции-члене объявление не найдено, поиск продолжается в классе. Просматриваются все члены класса.
• Если объявление имени в классе не найдено, поиск продолжится в области видимости перед определением функции-члена.
Обычно не стоит использовать имя другого члена класса как имя параметра в функции-члене. Но для демонстрации поиска имени нарушим это правило в функции dummy_fcn():
// обратите внимание: это сугубо демонстрационный код, отражающий
// плохую практику программирования. Обычно не стоит использовать
// одинаковое имя для параметра и функции-члена
int height; // определяет имя, впоследствии используемое в Screen
class Screen {
public:
typedef std::string::size_type pos;
void dummy_fcn(pos height) {
cursor = width * height; // какое имя height имеется в виду?
}
private:
pos cursor = 0;
pos height = 0, width = 0;
};
Когда компилятор обрабатывает выражение умножения в функции dummy_fcn(), он ищет имена сначала в пределах данной функции. Параметры функции находятся в области видимости функции. Таким образом, имя height, используемое в теле функции dummy_fcn(), принадлежит ее параметру.
В данном случае имя height параметра скрывает имя height переменной-члена класса. Если необходимо переопределить обычные правила поиска, то это можно сделать так:
// плохой подход: имена, локальные для функций-членов, не должны
// скрывать имена переменных-членов класса
void Screen::dummy_fcn(pos height) {
cursor = width * this->height; // переменная-член height
// альтернативный способ указания переменной-члена
cursor = width * Screen::height; // переменная-член height
}
Несмотря на то что член класса скрыт, его все равно можно использовать. Достаточно указать его полное имя, включающее имя класса, либо явно применить указатель this.
Значительно проще обеспечить доступ к переменной-члену height, присвоив параметру другое имя:
// хороший подход: не используйте имена переменных-членов для
// параметров или других локальных переменных
void Screen::dummy_fcn(pos ht) {
cursor = width * height; // переменная-член height
}
Теперь, когда компилятор будет искать имя height, в функции dummy_fcn() он его не найдет. Затем компилятор просмотрит класс Screen. Поскольку имя height используется в функции-члене dummy_fcn(), компилятор просмотрит все объявления членов класса. Несмотря на то что объявление имени height расположено после места его использования в функции dummy_fcn(), компилятор решает, что оно относится к переменной-члену height.
После поиска в области видимости класса продолжается поиск в окружающей области видимости
Если компилятор не находит имя в функции или в области видимости класса, он ищет его в окружающей области видимости. В данном случае имя height объявлено во внешней области видимости, перед определением класса Screen. Однако объект во внешней области видимости скрывается переменной-членом класса по имени height. Если необходимо имя из внешней области видимости, к нему можно обратиться явно, используя оператор области видимости:
// плохой подход: не скрывайте необходимые имена, которые
// определены в окружающих областях видимости
void Screen::dummy_fcn(pos height) {
cursor = width * ::height; // который height? Глобальный
}
Несмотря на то что глобальный объект был скрыт, используя оператор области видимости, доступ к нему вполне можно получить.
Поиск имен распространяется по всему файлу, где они были применены
Когда член класса определен вне определения класса, третий этап поиска его имени происходит не только в объявлениях глобальной области видимости, которые расположены непосредственно перед определением класса Screen, но и распространяется на остальные объявления в глобальной области видимости. Рассмотрим пример.
int height; // определяет имя, впоследствии используемое в Screen
class Screen {
public:
typedef std::string::size_type pos;
void setHeight(pos);
pos height = 0; // скрывает объявление height из внешней
// области видимости
};
Screen::pos verify(Screen::pos);
void Screen::setHeight(pos var) {
// var: относится к параметру
// height: относится к члену класса
// verify: относится к глобальной функции
height = verify(var);
}
Обратите внимание, что объявление глобальной функции verify() не видимо перед определением класса Screen. Но третий этап поиска имени включает область видимости, в которой присутствует определение члена класса. В данном примере объявление функции verify() расположено перед определением функции setHeight(), a потому может использоваться.
Упражнения раздела 7.4.1
Упражнение 7.34. Что произойдет, если поместить определение типа pos в последнюю строку класса Screen?
Упражнение 7.35. Объясните код, приведенный ниже. Укажите, какое из определений, Type или initVal, будет использовано для каждого из имен. Если здесь есть ошибки, найдите и исправьте их.
typedef string Type;
Type initVal();
class Exercise {
public:
typedef double Type;
Type setVal(Type);
Type initVal();
private:
int val;
};
Type Exercise::setVal(Type parm) {
val = parm + initVal();
return val;
}
7.5. Снова о конструкторах
Конструкторы — ключевая часть любого класса С++. Основы конструкторов рассматривались в разделе 7.1.4, а в этом разделе описаны некоторые из дополнительных возможностей конструкторов и подробности материала, приведенного ранее.
7.5.1. Список инициализации конструктора
Когда определяются переменные, они, как правило, инициализируются сразу, а не определяются и присваиваются впоследствии:
string foo = "Hello World!"; // определить и инициализировать
string bar; // инициализация по умолчанию пустой строкой
bar = "Hello World!"; // присвоение нового значения переменной bar
Аналогичное различие между инициализацией и присвоением относится к переменным-членам объектов. Если не инициализировать переменную-член явно в списке инициализации конструктора, она инициализируется значением по умолчанию прежде, чем выполнится тело конструктора. Например:
// допустимый, но не самый лучший способ создания конструктора
// класса Sales_data: нет инициализатора конструктора
Sales_data::Sales_data(const string &s,
unsigned cnt, double price) {
bookNo = s;
units_sold = cnt;
revenue = cnt * price;
}
Эта версия и исходное определение в разделе 7.1.4 дают одинаковый результат: по завершении конструктора переменные-члены содержат те же значения. Различие в том, что исходная версия инициализирует свои переменные-члены, тогда как эта версия присваивает значения им. Насколько существенно это различие, зависит от типа переменной-члена.
Иногда применение списка инициализации конструктора неизбежно
Зачастую, но не всегда, можно игнорировать различие между инициализацией и присвоением значения переменной-члену. Переменные-члены, являющиеся константой или ссылкой, должны быть инициализированы. Аналогично члены класса, для типа которых не определен стандартный конструктор, также следует инициализировать. Например:
class ConstRef {
public:
ConstRef(int ii);
private:
int i;
const int ci;
int &ri;
};
Переменные-члены ci и ri следует инициализировать как любой другой константный объект или ссылку. В результате отсутствие инициализатора конструктора для этих членов будет ошибкой:
// ошибка: ci и ri должны быть инициализированы
ConstRef::ConstRef(int ii) { // присвоения:
i = ii; // ok
ci = ii; // ошибка: нельзя присвоить значение константе
ri = i; // ошибка: ri никогда не будет инициализирована
}
К тому времени, когда начинает выполняться тело конструктора, инициализация уже завершена. Единственный шанс инициализировать константу или ссылочную переменную-член — в инициализаторе конструктора. Вот правильный способ написания этого конструктора:
// ok: явная инициализация констант и ссылок
ConstRef::ConstRef(int ii) : i(ii), ci (ii), ri(i) { }
Для предоставления значений переменным-членам, являющимся константой, ссылкой или классом, у которого нет стандартного конструктора, использование списка инициализации конструктора неизбежно.
Совет. Используйте списки инициализации конструктора
Во многих классах различие между инициализацией и присвоением связано исключительно с вопросом эффективности: зачем инициализировать переменную-член и присваивать ей значение, когда ее достаточно просто инициализировать.
Однако важней эффективности тот факт, что некоторые переменные-члены обязательно должны быть инициализированы. При стандартном использовании инициализаторов конструктора можно избежать неожиданных ошибок компиляции, когда класс обладает членом, требующим наличия списка инициализации.
Порядок инициализации переменных-членов класса
Нет ничего удивительного в том, что каждая переменная-член присутствует в списке инициализации конструктора только один раз. В конце концов, зачем переменной-члену два исходных значения?
Но что на самом деле неожиданно, так это то, что список инициализации конструктора задает только значения, используемые для инициализации переменных-членов, но не определяет порядок, в котором осуществляется инициализация.
Порядок инициализации переменных-членов задает их расположение при определении. Порядок расположения инициализаторов в списке инициализации конструктора не влияет на порядок инициализации.
Порядок инициализации зачастую не имеет значения. Но если одна из переменных-членов инициализируется с учетом значения другой, порядок их инициализации критически важен.
В качестве примера рассмотрим следующий класс:
class X {
int i;
int j;
public:
// ошибка: i инициализируется прежде j
X(int val) : j(val), i(j) { }
};
В данном случае список инициализации конструктора написан так, чтобы инициализировать переменную-член j значением val, а затем использовать переменную-член j для инициализации переменной-члена i. Но переменная-член i инициализируется первой. В результате попытка инициализации переменной-члена i осуществляется в момент, когда переменная-член j еще не имеет значения!
Некоторые компиляторы достаточно интеллектуальны, чтобы распознать опасность и выдать предупреждение о том, что переменные-члены в списке инициализации конструктора расположены в порядке, отличном от порядка их объявления.
Элементы списка инициализации конструктора имеет смысл располагать в том же порядке, в котором объявлены переменные-члены. Кроме того, старайтесь по возможности избегать применения одних переменных-членов для инициализации других.
Вообще, можно достаточно просто избежать любых проблем, связанных с порядком выполнения инициализации. Достаточно использовать параметры конструктора вместо переменных-членов объекта. Конструктор класса X, например, лучше было бы написать следующим образом:
X(int val) : i(val), j(val) { }
В этой версии порядок инициализации переменных-членов i и j не имеет значения.
Аргументы по умолчанию и конструкторы
Действие стандартного конструктора класса Sales_data подобно конструктору, получающему один строковый аргумент. Единственное отличие в том, что конструктор, получающий строковый аргумент, использует его для инициализации переменной-члена bookNo. Стандартный конструктор (неявно) использует стандартный конструктор типа string для инициализации переменной bookNo. Эти конструкторы можно переписать как единый конструктор с аргументом по умолчанию (см. раздел 6.5.1):
class Sales_data {
public:
// определить стандартный конструктор как получающий строковый
// аргумент
Sales_data(std::string s = ""): bookNo(s) { }
// остальные конструкторы без изменений
Sales_data(std::string s, unsigned cnt, double rev):
bookNo(s), units_sold(cnt), revenue(rev*cnt) { }
Sales_data(std::istream &is) { read(is, *this); }
// остальные члены, как прежде
};
Эта версия класса предоставляет тот же интерфейс, что и исходный из раздела 7.1.4. Обе версии создают тот же объект, когда никаких аргументов не предоставлено или когда предоставлен один строковый аргумент. Поскольку этот конструктор можно вызвать без аргументов, он считается стандартным конструктором класса.
Конструктор, предоставляющий аргументы по умолчанию для всех своих параметров, также считается стандартным конструктором.
Следует заметить, что, вероятно, не нужно использовать аргументы по умолчанию с конструктором Sales_data(), который получает три аргумента. Если пользователь предоставляет не нулевое количество проданных книг, следует также гарантировать, что пользователь предоставит и цену, по которой они были проданы.
Упражнения раздела 7.5.1
Упражнение 7.36. Следующий инициализатор ошибочен. Найдите и исправьте ошибку.
struct X {
X(int i, int j): base(i), rem(base % j) { }
int rem, base;
};
Упражнение 7.37. Используя версию класса Sales_data из этого раздела, определите, какой конструктор используется для инициализации каждой из следующих переменных, а также перечислите значения переменных-членов в каждом объекте:
Sales_data first_item(cin);
int main() {
Sales_data next;
Sales_data last("9-999-99999-9");
}
Упражнение 7.38. Конструктору, получающему аргумент типа istream&, можно предоставить объект cin как аргумент по умолчанию. Напишите объявление конструктора, использующего объект cin как аргумент по умолчанию.
Упражнение 7.39. Допустимо ли для конструктора, получающего строку, и конструктора, получающего тип istream&, иметь аргументы по умолчанию? Если нет, то почему?
Упражнение 7.40. Выберите одну из следующих абстракций (или абстракцию по собственному выбору). Определите, какие данные необходимы в классе. Предоставьте соответствующий набор конструкторов. Объясните свои решения.
(a) Book (b) Date (с) Employee
(d) Vehicle (e) Object (f) Tree
7.5.2. Делегирующий конструктор
Новый стандарт расширяет использование списков инициализации конструктора, позволяя определять так называемые делегирующие конструкторы (delegating constructor). Делегирующий конструктор использует для инициализации другой конструктор своего класса. Он "делегирует" некоторые (или все) свои задачи другому конструктору.
Подобно любому другому конструктору, делегирующий конструктор имеет список инициализации переменных-членов и тело функции. Список инициализации делегирующего конструктора содержит элемент, являющийся именем самого класса. Подобно другим инициализаторам переменных-членов класса, имя класса сопровождается заключенным в скобки списком аргументов. Список аргументов должен соответствовать другому конструктору в классе.
В качестве примера перепишем класс Sales_data так, чтобы использовать делегирующие конструкторы следующим образом:
class Sales_data {
public:
// неделегирующий конструктор инициализирует члены из соответствующих
// аргументов
Sales_data(std::string s, unsigned cnt, double price):
bookNo(s), units_sold(cnt), revenue(cnt*price) { }
// все другие конструкторы делегируют к другому конструктору
Sales_data(): Sales_data("", 0, 0) {}
Sales_data(std::string s): Sales_data(s, 0, 0) {}
Sales_data(std::istream &is): Sales_data()
{ read(is, *this); }
// другие члены как прежде
}
В этой версии класса Sales_data все конструкторы, кроме одного, делегируют свою работу. Первый конструктор получает три аргумента и использует их для инициализации переменных-членов, но ничего другого не делает. В этой версии класса определен стандартный конструктор, использующий для инициализации конструктор на три аргумента. Он также не делает ничего, поэтому его тело пусто. Конструктор, получающий строку, также делегирует работу версии на три аргумента.
Конструктор, получающий объект istream&, также делегирует свои действия. Он делегирует их стандартному конструктору, который в свою очередь делегирует их конструктору на три аргумента. Как только эти конструкторы заканчивают свою работу, запускается тело конструктора с аргументом istream&. Оно вызывает функцию read() для чтения данных из потока istream.
Когда конструктор делегирует работу другому конструктору, список инициализации и тело делегированного конструктора выполняются оба. В классе Sales_data тела делегируемых конструкторов пусты. Если бы тела конструкторов содержали код, то он выполнялся бы прежде, чем управление возвратилось бы к телу делегирующего конструктора.
Упражнения раздела 7.5.2
Упражнение 7.41. Перепишите собственную версию класса Sales_data, чтобы использовать делегирующие конструкторы. Добавьте в тело каждого конструктора оператор, выводящий сообщение всякий раз, когда он выполняется. Напишите объявления для создания объекта класса Sales_data любыми возможными способами. Изучите вывод и удостоверьтесь, что понимаете порядок выполнения делегирующих конструкторов.
Упражнение 7.42. Вернитесь к классу, написанному для упражнения 7.40 в разделе 7.5.1, и решите, может ли какой-нибудь из его конструкторов использовать делегирование. Если да, то напишите делегирующий конструктор (конструкторы) для своего класса. В противном случае рассмотрите список абстракций и выберите ту, которая, по вашему, использовала бы делегирующий конструктор. Напишите определение класса для этой абстракции.
7.5.3. Роль стандартного конструктора
Стандартный конструктор автоматически используется всякий раз, когда объект инициализируется по умолчанию. Инициализация по умолчанию осуществляется в следующем случае.
• При определении нестатических переменных (см. раздел 2.2.1) или массивов (см. раздел 3.5.1) в области видимости блока без инициализаторов.
• Когда класс, который сам обладает членами типа класса, использует синтезируемый стандартный конструктор (см. раздел 7.1.4).
• Когда переменные-члены типа класса не инициализируются явно в списке инициализации конструктора (см. раздел 7.1.4).
Инициализация значением по умолчанию осуществляется в следующем случае.
• Во время инициализации массива, когда предоставляется меньше инициализаторов, чем элементов массива (см. раздел 3.5.1).
• При определении локального статического объекта без инициализатора (см. раздел 6.1.1).
• Когда явно запрашивается инициализация значением по умолчанию в форме выражения Т(), где T — это имя типа. (Конструктор вектора, получающий один аргумент, чтобы определить размер вектора (см. раздел 3.3.1), использует аргумент этого вида для инициализации значением по умолчанию своего элемента.)
Чтобы использоваться в этих контекстах, у классов должен быть стандартный конструктор. Большинство этих контекстов должно быть вполне очевидным.
Однако значительно менее очевидным может быть влияние на классы, у которых есть переменные-члены без стандартного конструктора:
class NoDefault {
public:
NoDefault(const std::string&);
// далее дополнительные члены, но нет других конструкторов
};
struct А { // my_mem является открытой по умолчанию; см. раздел 1.2
NoDefault my_mem;
};
А а; // ошибка: невозможен синтезируемый конструктор для А
struct В {
В() {} // ошибка: нет инициализатора для b_member
NoDefault b_member;
};
На практике почти всегда имеет смысл предоставлять стандартный конструктор, если определены другие конструкторы.
Применение стандартного конструктора
Следующее объявление объекта obj компилируется без проблем. Но при попытке его использования компилятор жалуется на невозможность применения к функции синтаксиса доступа к члену.
Sales_data obj(); // ok: но определена функция, а не объект
if (obj.isbn() == Primer_5th_ed.isbn()) // ошибка: obj - функция
Проблема в том, что, несмотря на намерение объявить инициализированный значением по умолчанию объект obj, фактически была объявлена функция без параметров, возвращающая объект типа Sales_data.
Чтобы правильно определить объект, использующий стандартный конструктор для инициализации, следует убрать пустые круглые скобки:
// ok: obj - объект, инициализированный значением по умолчанию
Sales_data obj;
Распространенной ошибкой среди новичков в С++ является объявление объекта, инициализированного стандартным конструктором, следующим образом:
Sales_data obj(); // упс! Это объявление функции, а не создание объекта
Sales_data obj2; // ok: obj2 - это объект, а не функция
Упражнения раздела 7.5.3
Упражнение 7.43. Предположим, имеется класс NoDefault, у которого есть конструктор, получающий параметр типа int, но нет стандартного конструктора. Определите класс С, у которого есть переменная-член типа NoDefault. Определите стандартный конструктор для класса С.
Упражнение 7.44. Допустимо ли следующее объявление? Если нет, то почему?
vector
Упражнение 7.45. Определите вектор, содержащий объекты типа С из предыдущего упражнения?
Упражнение 7.46. Которое из следующих утверждений, если таковое имеется, ложно? Почему?
(a) Класс должен предоставить по крайней мере один конструктор.
(b) Стандартный конструктор — это конструктор с пустым списком параметров.
(c) Если для класса не нужно никаких значений по умолчанию, то класс не должен предоставлять стандартный конструктор.
(d) Если класс не определяет стандартный конструктор, компилятор сам создает конструктор, который инициализирует каждую переменную-член значением по умолчанию соответствующего типа.
7.5.4. Неявное преобразование типов класса
Как упоминалось в разделе 4.11, язык С++ автоматически осуществляет преобразование некоторых встроенных типов. Обращалось также внимание на то, что классы тоже могут определять неявные преобразования. Каждый конструктор, который может быть вызван с одним аргументом, определяет неявное преобразование в тип класса. Такие конструкторы иногда упоминают как конструкторы преобразования (converting constructor). Определение преобразования из типа класса в другой тип рассматривается в разделе 14.9.
Конструктор, который может быть вызван с одиночным аргументом, вполне позволяет определить неявное преобразование из типа параметра в тип класса.
Конструкторы класса Sales_data, получающие строку и объект класса istream, оба определяют неявные преобразования из этих типов в тип Sales_data. Таким образом, можно использовать тип string или istream там, где ожидается объект типа Sales_data:
string null_book = "9-999-99999-9";
// создает временный объект типа Sales_data
// с units_sold и revenue равными 0 и bookNo равным null_book
item.combine(null_book);
Здесь происходит вызов функции-члена combine() класса Sales_data со строковым аргументом. Этот вызов совершенно корректен; компилятор автоматически создаст объект класса Sales_data из данной строки. Этот вновь созданный (временный) объект класса Sales_data передается функции combine(). Поскольку параметр функции combine() является ссылкой на константу, этому параметру можно передать временный объект.
Допустимо только одно преобразование типов класса
В разделе 4.11.2 обращалось внимание на то, что компилятор автоматически применит только одно преобразование типов класса. Например, следующий код ошибочен, поскольку он неявно использует два преобразования:
// ошибка: требует двух пользовательских преобразований:
// (1) преобразование "9-999-99999-9" в string
// (2) преобразование временной строки в Sales_data
item.combine("9-999-99999-9");
Если данный вызов необходим, это можно сделать при явном преобразовании символьной строки в объект класса string или в объект класса Sales_data:
// ok: явное преобразование в string,
// неявное преобразование в Sales_data
item.combine(string("9-999-99999-9"));
// ok: неявное преобразование в string,
// явное преобразование в Sales_data
item.combine(Sales_data("9-999-99999-9"));
Преобразования типов класса не всегда полезны
Желательно ли преобразование типа string в Sales_data, зависит от конкретных обстоятельств. В данном случае это хорошая идея. Строка в переменной null_book, вероятнее всего, соответствует несуществующему ISBN.
Преобразование из istream в Sales_data более проблематично:
// использует конструктор istream при создании объекта для передачи
// функции combine
item.combine(cin);
Этот код неявно преобразует объект cin в объект класса Sales_item. Это преобразование осуществляет тот конструктор класса Sales_data, который получает тип istream. Этот конструктор создает (временный) объект класса Sales_data при чтении со стандартного устройства ввода. Затем этот объект передается функции same_isbn().
Этот объект класса Sales_item временный (см. раздел 2.4.1). По завершении функции combine() никакого доступа к нему не будет. Фактически создается объект, удаляющийся после того, как его значение добавляется в объект item.
Предотвращение неявных преобразований, осуществляемых конструктором
Чтобы предотвратить использование конструктора в контексте, который требует неявного преобразования, достаточно объявить его явным (explicit constructor) с использованием ключевого слова explicit:
class Sales_data {
public:
Sales_data() = default;
Sales_data(const std::string &s, unsigned n, double p):
bookNo(s), units_sold(n), revenue (p*n) { }
explicit Sales_data(const std::string &s): bookNo(s) { }
explicit Sales_data(std::istream&); // остальные члены, как прежде
};
Теперь ни один из конструкторов не применим для неявного создания объектов класса Sales_data. Ни один из предыдущих способов применения теперь не сработает:
item.combine(null_book); // ошибка: конструктор string теперь явный
item.combine(cin); // ошибка: конструктор istream теперь явный
Ключевое слово explicit имеет значение только для тех конструкторов, которые могут быть вызваны с одним аргументом. Конструкторы, требующие большего количества аргументов, не используются для неявного преобразования, поэтому нет никакой необходимости определять их как explicit. Ключевое слово explicit используется только в объявлениях конструкторов в классе. В определении вне тела класса его не повторяют.
// ошибка: ключевое слово explicit допустимо только для
// объявлений конструкторов в заголовке класса
explicit Sales_data::Sales_data(istream& is) {
read(is, *this);
}
Явные конструкторы применяются только для прямой инициализации
Одним из контекстов, в котором происходит неявное преобразования, является использование формы инициализации копированием (со знаком =) (см. раздел 3.2.1). С этой формой инициализации нельзя использовать явный конструктор; придется использовать прямую инициализацию:
Sales_data item1(null_book); // ok: прямая инициализация
// ошибка: с явным конструктором нельзя использовать форму
// инициализации копированием
Sales_data item2 = null_book;
Явный конструктор применим только с прямой формой инициализации (см. раздел 3.2.1). Кроме того, компилятор не будет использовать этот конструктор в автоматическом преобразовании.
Применение явных конструкторов для преобразований
Хотя компилятор не будет использовать явный конструктор для неявного преобразования, его можно использовать для преобразования явно:
// ok: аргумент - явно созданный объект класса Sales_data
item.combine(Sales_data(null_book));
// ok: static_cast может использовать явный конструктор
item.combine(static_cast
В первом вызове конструктор Sales_data() используется непосредственно. Этот вызов создает временный объект класса Sales_data, используя конструктор Sales_data(), получающий строку. Во втором вызове используется оператор static_cast (см. раздел 4.11.3) для выполнения явного, а не неявного преобразования. В этом вызове оператор static_cast использует для создания временного объекта класса Sales_data конструктор с параметром типа istream.
Библиотечные классы с явными конструкторами
У некоторых библиотечных классов, включая уже использованные ранее, есть конструкторы с одним параметром.
• Конструктор класса string, получающий один параметр типа const char* (см. раздел 3.2.1), не является явным.
• Конструктор класса vector, получающий размер вектора (см. раздел 3.3.1), является явным.
Упражнения раздела 7.5.4
Упражнение 7.47. Объясните, должен ли быть явным конструктор Sales_data(), получающий строку. Каковы преимущества объявления конструктора явным? Каковы недостатки?
Упражнение 7.48. С учетом того, что конструктор Sales_data() не является явным, какие операции происходят во время следующих определений:
string null_isbn("9-999-99999-9");
Sales_data item1(null_isbn);
Sales_data item2("9-999-99999-9");
Что будет при явном конструкторе Sales_data()?
Упражнение 7.49. Объясните по каждому из следующих трех объявлений функции combine(), что происходит при вызове i.combine(s), где i — это объект класса Sales_data, a s — строка:
(a) Sales_data &combine(Sales_data);
(b) Sales_data &combine(Sales_data&);
(c) Sales_data &combine(const Sales_data&) const;
Упражнение 7.50. Определите, должен ли какой-либо из конструкторов вашего класса Person быть явным.
Упражнение 7.51. Как, по вашему, почему вектор определяет свой конструктор с одним аргументом как явный, а строка нет?
7.5.5. Агрегатные классы
Агрегатный класс (aggregate class) предоставляет пользователям прямой доступ к своим членам и имеет специальный синтаксис инициализации. Класс считается агрегатным в следующем случае.
• Все его переменные-члены являются открытыми (public).
• Он не определяет конструкторы.
• У него нет никаких внутриклассовых инициализаторов (см. раздел 2.6.1).
• У него нет никаких базовых классов или виртуальных функций, связанных с классом средствами, которые рассматриваются в главе 15.
Например, следующий класс является агрегатным:
struct Data {
int ival;
string s;
};
Для инициализации переменных-членов агрегатного класса можно предоставить заключенный в фигурные скобки список инициализаторов для переменных-членов:
// val1.ival = 0; val1.s = string("Anna")
Data val1 = { 0, "Anna" };
Инициализаторы должны располагаться в порядке объявления переменных-членов. Таким образом, сначала располагается инициализатор для первой переменной-члена, затем для второй и т.д. Следующей пример ошибочен:
// ошибка: нельзя использовать "Anna" для инициализации ival или 1024
// для инициализации s
Data val2 = { "Anna" , 1024 };
Как и при инициализации элементов массива (см. раздел 3.5.1), если в списке инициализаторов меньше элементов, чем переменных-членов класса, последние переменные-члены инициализируются значением по умолчанию. Список инициализаторов не должен содержать больше элементов, чем переменных-членов у класса.
Следует заметить, что у явной инициализации переменных-членов объекта класса есть три существенных недостатка.
• Она требует, чтобы все переменные-члены были открытыми.
• Налагает дополнительные обязанности по правильной инициализации каждой переменной-члена каждого объекта на пользователя класса (а не на его автора). Такая инициализация утомительна и часто приводит к ошибкам, поскольку достаточно просто забыть инициализатор или предоставить неподходящее значение.
• Если добавляется или удаляется переменная-член, придется изменить все случаи инициализации.
Упражнения раздела 7.5.5
Упражнение 7.52. На примере первой версии класса Sales_data из раздела 2.6.1 объясните следующую инициализацию. Найдите и исправьте возможные ошибки.
Sales_data item = {"978-0590353403", 25, 15.99};
7.5.6. Литеральные классы
В разделе 6.5.2 упоминалось, что параметры и возвращаемое значение функции constexpr должны иметь литеральные типы. Кроме арифметических типов, ссылок и указателей, некоторые классы также являются литеральными типами. В отличие от других классов, у классов, являющихся литеральными типами, могут быть функции-члены constexpr. Такие функции-члены должны отвечать всем требованиям функций constexpr. Эти функции-члены неявно константные (см. раздел 7.1.2).
Агрегатный класс (см. раздел 7.5.5), все переменные-члены которого имеют литеральный тип, является литеральным классом. Неагрегатный класс, соответствующий следующим ограничениям, также является литеральным классом.
• У всех переменных-членов должен быть литеральный тип.
• У класса должен быть по крайней мере один конструктор constexpr.
• Если у переменной-члена есть внутриклассовый инициализатор, инициализатор для переменной-члена встроенного типа должен быть константным выражением (см. раздел 2.4.4). Если переменная-член имеет тип класса, инициализатор должен использовать его собственный конструктор constexpr.
• Класс должен использовать заданное по умолчанию определение для своего деструктора — функции-члена класса, удаляющего объекты типа класса (см. раздел 7.1.5).
Конструкторы constexpr
Хотя конструкторы не могут быть константными (см. раздел 7.1.4), в литеральном классе они могут быть функциями constexpr (см. раздел 6.5.2). Действительно, литеральный класс должен предоставлять по крайней мере один конструктор constexpr.
Конструктор constexpr может быть объявлен как = default (см. раздел 7.1.4) или как удаленная функция, которые будут описаны в разделе 13.1.6. В противном случае конструктор constexpr должен отвечать требованиям к конструкторам (у него не может быть оператора return) и к функциям constexpr (его исполняемый оператор может иметь единственный оператор return) (см. раздел 6.5.2). В результате тело конструктора constexpr обычно пусто. Определению конструктора constexpr предшествует ключевое слово constexpr:
class Debug {
public:
constexpr Debug(bool b = true): hw(b), io(b), other(b) { }
constexpr Debug(bool h, bool i, bool o):
hw(h), io(i), other(o) { }
constexpr bool any() { return hw || io || other; }
void set_io(bool b) { io = b; }
void set_hw(bool b) { hw = b; }
void set_other(bool b) { hw = b; }
private:
bool hw; // аппаратная ошибка, отличная от ошибки IO
bool io; // ошибка IO
bool other; // другие ошибки
};
Конструктор constexpr должен инициализировать каждую переменную-член. Инициализаторы должны либо использовать конструктор constexpr, либо быть константным выражением.
Конструктор constexpr используется и для создания объектов constexpr, и для параметров или типов возвращаемого значения функций constexpr:
constexpr Debug io_sub(false, true, false); // отладка IO
if (io_sub.any()) // эквивалент if (true)
cerr << "print appropriate error messages" << endl;
constexpr Debug prod(false); // при выпуске без отладки
if (prod.any()) // эквивалент if (false)
cerr << "print an error message" << endl;
Упражнения раздела 7.5.6
Упражнение 7.53. Определите собственную версию класса Debug.
Упражнение 7.54. Должны ли члены класса Debug, начинающиеся с set_, быть объявлены как constexpr? Если нет, то почему?
Упражнение 7.55. Является ли класс Data из раздела 7.5.5 литеральным? Если нет, то почему? Если да, то почему он является литеральным.
7.6. Статические члены класса
Иногда классы нуждаются в членах, ассоциированных с самим классом, а не с его индивидуальными объектами. Например, класс банковского счета, возможно, нуждается в переменной-члене, представляющей базовую процентную ставку. В данном случае мы хотели бы ассоциировать процентную ставку с классом, а не с каждым конкретным объектом. С точки зрения эффективности нет никаких причин хранить процентную ставку для каждого объекта. Однако важней всего то, что если процентная ставка изменится, каждый объект сразу использует новое значение.
Объявление статических членов
Чтобы сделать член класса статическим, его объявление следует предварить ключевым словом static. Статические члены, как и любые другие, могут быть открытыми или закрытыми. Статическая переменная-член может быть константой, ссылкой, массивом, классом и т.д.
В качестве примера определим класс, представляющий банковскую учетную запись:
class Account {
public:
void calculate() { amount += amount * interestRate; }
static double rate() { return interestRate; }
static void rate(double);
private:
std::string owner;
double amount;
static double interestRate;
static double initRate();
};
Статические члены класса существуют вне конкретного объекта. Объекты не содержат данные, связанные со статическими переменными-членами. Таким образом, каждый объект класса Account будет содержать две переменные-члена — owner и amount. Есть только один объект interestRate, совместно используемый всеми объектами Account.
Аналогично статические функции-члены не связаны с конкретным объектом; у них нет указателя this. В результате статические функции-члены не могут быть объявлены константами и к указателю this нельзя обратиться в теле статического члена класса. Это ограничение применимо и к явному использованию указателя this, и к неявному, при вызове не статического члена класса.
Использование статических членов класса
К статическому члену класса можно обратиться непосредственно, используя оператор области видимости:
double r;
r = Account::rate(); // доступ к статическому члену при помощи
// оператора области видимости
Даже при том, что статические члены не являются частью отдельных объектов, для доступа к статическому члену класса можно использовать объект, ссылку или указатель на тип класса:
Account ac1;
Account *ac2 = &ac1;
// эквивалентные способы вызова статической функции
rate r = ac1.rate(); // через объект класса Account или ссылку
r = ac2->rate(); // через указатель на объект класса Account
Функции-члены могут использовать статические члены непосредственно, без оператора области видимости:
class Account {
public:
void calculate() { amount += amount * interestRate; }
private:
static double interestRate; // остальные члены как прежде
};
Определение статических членов
Подобно любой другой функции-члену, статическую функцию-член можно определить как в, так и вне тела класса. Когда статический член класса определяется вне его тела класса, ключевое слово static повторять не нужно, оно присутствует только в объявлении в теле класса:
void Account::rate(double newRate) {
interestRate = newRate;
}
При обращении к статическому члену класса вне тела класса, подобно любому другому члену класса, необходимо указать класс, в котором он определен. Но ключевое слово static используется только при объявлении в теле класса. В определении ключевое слово static не используется.
Поскольку статические переменные-члены не принадлежат индивидуальным объектам класса, они не создаются при создании объектов класса. В результате они не инициализируются конструкторами класса. Кроме того, статическую переменную-член вообще нельзя инициализировать в классе. Каждую статическую переменную-член следует определить и инициализировать вне тела класса. Как и любой другой объект, статическая переменная-член может быть определена только однажды.
Как и глобальные объекты (см. раздел 6.1.1), статические переменные-члены определяются вне любой функции. Следовательно, сразу после определения они продолжают существовать, пока программа не завершит работу.
Статические члены определяют точно так же, как и функции-члены класса вне класса. Указывается тип объекта, затем имя класса, оператор области видимости и собственное имя члена:
// определить и инициализировать статический член класса double
Account::interestRate = initRate();
Этот оператор определяет статический объект по имени interestRate, который является членом класса Account и имеет тип double. Подобно другим членам класса, определение статического находится в области видимости того класса, где определено его имя. В результате статическую функцию-член initRate() можно использовать для инициализации переменной rate непосредственно, без уточнения класса. Обратите внимание: несмотря на то, что функция-член initRate() является закрытой, ее можно использовать для инициализации объекта interestRate. Определение переменной-члена interestRate, подобно любому другому определению, находится в области видимости класса, а следовательно, имеет доступ к закрытым членам класса.
Наилучший способ гарантировать, что объект будет определен только один раз, — разместить определение статических переменных-членов в том же файле, который содержит определение не встраиваемых функций-членов класса.
Инициализация статических переменных-членов в классе
Обычно статические переменные-члены не могут быть инициализированы в теле класса. Но можно предоставить внутриклассовые инициализаторы для тех статических переменных-членов, которые имеют тип целочисленных констант, или статических членов constexpr литерального типа (см. раздел 7.5.6). Инициализаторы должны быть константными выражениями. Такие члены сами являются константными выражениями; они могут быть использованы там, где ожидается константное выражение. Например, инициализированную статическую переменную-член можно использовать для определения размерности члена типа массива:
class Account {
public:
static double rate() { return interestRate; }
static void rate(double);
private:
static constexpr int period = 30; // period - константное выражение
double daily_tbl[period];
};
Если член класса используется только в контекстах, где компилятор может подставить его значение, то инициализированная константа или статическое константное выражение не следует определять отдельно. Но если член класса используется в контексте, где значение не может быть подставлено, то определение для этого члена необходимо.
Например, если переменная period используется только для определения размерности массива daily_tbl, нет никакой необходимости определять ее за пределами класса Account. Но если пропустить определение, то даже, казалось бы, тривиальное изменение в программе может привести к отказу компиляции. Например, если передать переменную-член Account::period функции, получающей параметр типа const int&, то переменную period следует определить.
Если инициализатор предоставляется в классе, определение члена класса не должно задавать исходного значения:
// определение статического члена без инициализатора
constexpr int Account::period; // инициализатор предоставлен в
// определении класса
Даже если константная статическая переменная-член инициализируется в теле класса, она должна определяться вне определения класса.
Статические члены можно применять так, как нельзя применять обычные
Как уже упоминалось, статические члены существуют независимо от конкретного объекта. В результате они применимы такими способами, которые недопустимы для нестатических переменных-членов. Например, у статической переменной-члена может быть незавершенный тип (см. раздел 7.3.3). В частности, статическая переменная-член может иметь тип, совпадающий с типом класса, членом которого она является. Нестатическая переменная-член может быть только указателем или ссылкой на объект собственного класса:
class Bar {
public:
// ...
private:
static Bar mem1; // ok: тип статического члена может быть
// незавершенным
Bar *mem2; // ok: тип указателя-члена может быть незавершенным
Bar mem3; // ошибка: тип переменной-члена должен быть
// завершенным
};
Еще одно различие между статическими и обычными членами в том, что статический член можно использовать как аргумент по умолчанию (см. раздел 6.5.1):
class Screen {
public:
// bkground ссылается на статический член класса
// объявлено позже, в определении класса
Screen& clear(char = bkground);
private:
static const char bkground;
};
Нестатическая переменная-член не может использоваться как аргумент по умолчанию, поскольку ее значение является частью объекта, которому она принадлежит. Использование нестатической переменной-члена как аргумента, по умолчанию не предоставляющего объект, которому она принадлежит, также является ошибкой.
Упражнения раздела 7.6
Упражнение 7.56. Что такое статический член класса? Каковы преимущества статических членов? Чем они отличаются от обычных членов?
Упражнение 7.57. Напишите собственную версию класса Account.
Упражнение 7.58. Какие из следующих объявлений и определений статических переменных-членов являются ошибочными? Объясните почему.
// example.h
class Example {
public:
static double rate = 6.5;
static const int vecSize = 20;
static vector
};
// example.C
#include "example.h"
double Example::rate;
vector
Резюме
Классы — это фундаментальный компонент языка С++. Классы позволяют определять новые типы, наилучшим образом приспособленные к задачам конкретного приложения и позволяющие сделать их короче и проще в модификации.
Основой классов являются абстракция данных (способность определять данные и функции-члены) и инкапсуляция (способность защитить члены класса от общего доступа). Инкапсуляция класса достигается определением членов его реализации закрытыми. Классы могут предоставить доступ к своему не открытому члену, объявив другой класс или функцию дружественной.
Классы могут определять конструкторы — специальные функции-члены, контролирующие инициализацию объектов. Конструкторы могут быть перегружены. Для инициализации всех переменных-членов конструкторы должны использовать список инициализации конструктора.
Классы позволяют объявлять переменные-члены изменяемыми (mutable) или статическими (static). Изменяемая переменная-член никогда не становится константой — ее значение может быть изменено даже в константной функции-члене. Статической может быть как функция, так и переменная-член. Статические члены класса существуют независимо от объектов данного класса.
Классы могут также определить изменяемые (mutable) и статические (static) члены. Изменяемая переменная-член никогда не становится константой; ее значение может быть изменено даже в константной функции-члене. Статический член может быть функцией или переменной; статические члены существуют независимо от объектов типа класса.
Термины
= default. Синтаксис, используемый после списка параметров объявления стандартного конструктора класса, чтобы сообщить компилятору о необходимости создать конструктор, даже если у класса есть другие конструкторы.
Абстрактный тип данных (abstract data type). Структура данных, инкапсулирующая (скрывающая) свою реализацию.
Абстракция данных (data abstraction). Технология программирования, сосредоточенная на интерфейсе типа. Абстракция данных позволяет программистам игнорировать детали реализации типа, интересуясь лишь его возможностями. Абстракция данных является основой как объектно-ориентированного, так и обобщенного программирования.
Агрегатный класс (aggregate class). Класс только с открытыми переменными-членами, без внутриклассовых инициализаторов или конструкторов. Члены агрегатного класса могут быть инициализированы заключенным в фигурные скобки списком инициализаторов.
Делегирующий конструктор (delegating constructor). Конструктор со списком инициализации, один элемент которого определяет другой конструктор того же класса для инициализации.
Дружественные отношения (friend). Механизм, при помощи которого класс предоставляет доступ к своим не открытым членам. Дружественные классы и функции имеют те же права доступа, что и члены самого класса. Дружественными могут быть объявлены как классы, так и отдельные функции.
Закрытый член класса (private member). Члены, определенные после спецификатора доступа private; доступный только для друзей и других членов класса. Закрытыми обычно объявляют переменные-члены и вспомогательные функции, используемые классом, но не являющиеся частью интерфейса типа.
Изменяемая переменная-член (mutable data member). Переменная-член, которая никогда не становится константой, даже когда является членом константного объекта. Значение изменяемой переменной-члена вполне может быть изменено в константной функции.
Инкапсуляция (encapsulation). Разделение реализации и интерфейса. Инкапсуляция скрывает детали реализации типа. В языке С++ инкапсуляция предотвращает доступ обычного пользователя класса к его закрытым членам.
Интерфейс (interface). Открытые (public) операции, поддерживаемые типом. Обычно интерфейс не включает переменные-члены.
Класс (class). Механизм языка С++, позволяющий создавать собственные абстрактные типы данных. Классы могут содержать как данные, так и функции. Класс определяет новый тип и новую область видимости.
Ключевое словоclass. Следующие после ключевого слова class объявления класса считаются по умолчанию закрытыми (private).
Ключевое словоstruct. Следующие после ключевого слова struct объявления структуры считаются по умолчанию открытыми (public).
Константная функция-член (const member function). Функция-член, которая не может изменять обычные (т.е. нестатические и неизменяемые) переменные-члены объекта. Указатель this константного члена класса является указателем на константу. Функция-член может быть перегружена на основании того, является ли она константной или нет.
Конструктор (constructor). Специальная функция-член, обычно инициализирующая объекты. Конструктор должен присвоить каждой переменной-члену хорошо продуманное исходное значение.
Конструктор преобразования (converting constructor). Неявный конструктор, который может быть вызван с одиночным аргументом. Конструктор преобразования используется для неявного преобразования типа аргумента в тип класса.
Спецификатор доступа (access specifier). Ключевые слова public и private определяют, доступны ли данные члены для пользователей класса или только его друзьям и членам. Спецификаторы могут присутствовать многократно в пределах класса. Каждый спецификатор устанавливает степень доступа для последующих членов до следующего спецификатора.
Незавершенный тип (incomplete type). Тип, который уже объявлен, но еще не определен. Использовать незавершенный тип для определения члена класса или переменной нельзя. Однако ссылки или указатели на незавершенные типы вполне допустимы.
Область видимости класса (class scope). Каждый класс определяет область видимости. Область видимости класса сложнее, чем другие области видимости, поскольку определенные в теле класса функции-члены могут использовать имена, которые появятся уже после определения.
Объявление класса (class declaration). Ключевое слово class (или struct), сопровождаемое именем класса и точкой с запятой. Если класс объявлен, но не определен, то это незавершенный тип.
Открытый член класса (public member). Члены, определенные после спецификатора доступа public; доступны для любого пользователя класса. Обычно в разделах public определяют только те функции, которые определяют интерфейс класса.
Поиск имени (name lookup). Процесс поиска объявления используемого имени.
Предварительное объявление (forward declaration). Объявление имени еще не определенного класса. Как правило, используется для ссылки на объявление класса до его определения. См. незавершенный тип.
Реализация (implementation). Как правило, закрытые (private) члены класса, определяющие данные и все операции, которые не предназначены для использования кодом, применяющим тип.
Синтезируемый стандартный конструктор (synthesized default constructor). Компилятор самостоятельно создает (синтезирует) стандартный конструктор для классов, у которых не определено никаких конструкторов. Этот конструктор инициализирует переменные-члены типа класса, запуская их стандартные конструкторы, а переменные-члены встроенных типов остаются неинициализированными.
Список инициализации конструктора (constructor initializer list). Перечень исходных значений переменных-членов класса. Инициализация переменных-членов класса значениями списка осуществляется прежде, чем выполняется тело конструктора. Переменные-члены класса, которые не указаны в списке инициализации, инициализируются неявно, своими значениями по умолчанию.
Стандартный конструктор (default constructor). Конструктор без параметров.
Указательthis. Значение, неявно передаваемое как дополнительный аргумент каждой нестатической функции-члену. Указатель this указывает на объект, функция которого вызывается.
Функция-член (member function). Член класса, являющийся функцией. Обычные функции-члены связаны с объектом класса при помощи неявного указателя this. Статические функции-члены с объектом не связаны и указателя this не имеют. Функции-члены вполне могут быть перегружены; если это так, то неявный указатель this участвует в подборе функции.
Явный конструктор (explicit constructor). Конструктор с одним аргументом, который, однако, не может быть использован для неявного преобразования. Объявление явного конструктора предваряется ключевым словом explicit.