msiu_logo
Кафедра информационных систем и технологий
http://edu.msiu.ru

Знакомство с языком C++ (часть 1)

А.Г. Верещагин

3 февраля 2014

Я придумал термин «объектно-ориентированный», но я вовсе не имел в виду C++.
— Алан Кэй. Создатель Smalltalk.

Если не выходить за границу «объектно-ориентированных» методов, чтобы остаться в рамках «хорошего программирования», то в итоге обязательно получается нечто, что является в основном бессмысленным.
— Бьёрн Страуструп. Создатель C++.

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

При помощи C вы легко можете выстрелить себе в ногу. При помощи C++ это сделать сложнее, но если это произойдёт, вам оторвёт всю ногу целиком.
— Бьёрн Страуструп. Создатель C++.

Любая достаточно сложная программа на C или Fortran содержит заново написанную, неспецифицированную, глючную и медленную реализацию половины языка Common Lisp.
— Десятое правило Гринспена.

Есть всего два типа языков программирования: те, на которые люди всё время ругаются, и те, которые никто не использует.
— Бьёрн Страуструп. Создатель C++.

Введение

C++ — компилируемый статически типизированный язык программирования общего назначения. Язык поддерживает процедурное, объектно-ориентированное, функциональное, обобщённое и метапрограммирование. Лучше всего воспринимать C++ не как один язык, а как конгломерат взаимосвязанных языков. Для каждого подъязыка существуют собственные приёмы и правила программирования:

С++ — язык крайне неоднозначный. Исторически данный язык впитал в себя множество независимых элементов из других языков программирования, что сделало его чрезвычайно сложным и противоречивым. Критиками утверждается, что для любого случая применения языка найдётся альтернативный инструмент, позволяющий решить задачу более эффективно и качественно. Сторонники C++ в ответ справедливо замечают, что по спектру своих возможностей язык существенно шире большинства других, считая это достоинством, несмотря на вынужденное применение компромиссов. Одно и то же свойство языка рассматривается и как его главное достоинство, и как главный недостаток. Тем не менее язык C++ остаётся «классическим языком» и к настоящему времени не потерял популярности и веса в индустрии, что обуславливает необходимость знакомства с ним в той или иной степени. Ответ же на вопрос о том, использовать ли данный инструмент на практике или нет, как обычно, можно дать лишь в контексте конкретной задачи и ситуации: где-то окажется предпочтительным «универсальный язык», а где-то — «композиция языков специализированных».

История языка C++

Идея создания языка C++ возникла приблизительно на 10 лет позже появления языка C. Бьёрн Страуструп — создатель C++ — задумал свой язык как расширение языка C дополнительными возможностями, связанными в первую очередь с объектно-ориентированным программированием. Данная версия языка называлась «C с классами». Идеи универсальности, эффективности («не платить за то, что не используется»), мультипарадигмальности, свободы выбора, максимальной обратной совместимости с C, платформонезависмости были с самого начала заложены в философию языка. Через несколько лет после начала существования язык был дополнительно расширен новыми возможностями, после чего и получил своё привычное название: C++.

Первой «спецификацией» языка можно считать первое издание книги «Язык программирования C++» Бьёрна Страуструпа. Первый «настоящий» стандарт языка возник лишь в 1998 году и известен под названием C++98. К тому времени в язык были включены все его «классические» возможности. Работа по стандартизации продолжает идти по сей день. Последней официально опубликованной версией стандарта после длительного перерыва является стандарт C++11 или ISO/IEC 14882:2011. Стандарт внёс в язык множество изменений и улучшений, как расширяющих ядро языка, так и стандартную библиотеку. Из-за спешки (стандарт планировался к публикации ещё в 2009 году) рассмотрение ряда нововведений было отложено до выхода стандарта C++14, вносящего скорее косметические улучшения и доработки, нежели обеспечивающего переход языка на новый качественный уровень. После C++14 планируется выпустить стандарт C++17. Работа ведётся сразу по нескольким направлениям (работа с файловой системой, сетевое программирование, параллельное и конкурентное программирование и пр.); по каждому из направлений планируются к выпуску т.н. «технические спецификации», которые в последствии и будут объединены в новый стандарт. Новые версии стандарта смогут выходить чаще.

Тексты официальных стандартов ISO/IEC находятся в закрытом доступе, но черновики работы комитета открыты и регулярно обновляются. Возможно как найти черновик, наиболее близкий к официальному стандарту, так и быть в курсе самых последних изменений, которым только предстоит войти в новый стандарт.

C и C++

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

Базовые возможности языка C++

Далее приводится обзор возможностей языка C++, отсутствующих в языке C. Среди таких возможностей многие относятся к объектно-ориентированной природе языка. Так же в отдельную категорию можно выделить возможности, связанные с механизмом шаблонов. Все остальные возможности языка C++ условно можно назвать «базовыми». В обзоре уделяется внимание лишь наиболее важным, по мнению автора, возможностям C++. За более подробной информацией следует обратиться к специализированным источникам.

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

Новые типы данных

В языке C++ в сравнении с C имеются несколько новых встроенных типов данных. К их числу можно отнести тип bool, представляющий значения логических выражений. Переменные данного типа могут принимать два возможных значения: «истину» и «ложь». Для обозначения «истины» используется литерал true, а для обозначения «лжи» — литерал false. Совместимость с языком C достигается за счёт того, что допускаются неявные преобразования между логическим и числовыми типами данных. Так же допускаются неявные преобразования указателей к логическому типу. Нулевой указатель и 0 преобразуются в false, остальные значения — в true. Наоборот, false может быть неявно преобразовано в 0, а true — в 1.

Для обозначения нулевого указателя в C++ существует специальный литерал nullptrC++11 типа std::nullprt_tC++11. Данная величина может быть неявно преобразована к любому указателю, но не к числовому типу данных. Таким образом устраняется недостаток макроса NULL, который в C++ имеет, по сути, числовой тип и равен 0. (В C++ невозможно неявное преобразование числового типа данных в указатель, но для величины 0 делается исключение.)

Ссылки

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

Некоторые ограничения ссылок:

Нововведением стандарта C++11 стала так называемая «семантика переноса» (move semantics), напрямую связанная со ссылками на временные безымянные величины, которыми могут быть, например, результаты промежуточных вычислений или значения, возвращаемые функциями. У таких величин нельзя взять адрес. До появления стандарта C++11 для того, чтобы сохранить значение такой величины, необходимо было её скопировать, тогда как стандарт C++11 допускает перенос таких величин. Разница между двумя этими понятиями рассматривается здесь, но перенос обычно можно считать более эффективной (не менее эффективной) операцией чем копирование.

Операторы преобразования типов

Классическая операция преобразования типов языка C обладает некоторыми недостатками:

В языке C++ вводится несколько дополнительных операторов преобразования типов, каждый из которых выполняет различные задачи.

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

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

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

В C++ для решения проблемы уникальности глобальных идентификаторов вводится механизм пространств имён. Глобальные объявления в языке C++, относящиеся к одному модулю, можно группировать внутри блока пространства имён, используя конструкцию namespace N { ... }, где N — имя пространства. Внутри текущего пространства имён для обращения к содержащемуся в нём идентификатору достаточно, как и раньше, указания имени данного идентификатора. Если идентификатор не найден в текущем пространстве имён, будет осуществлён поиск во внешнем окружении. Пространства имён могут быть вложены друг в друга. Имена пространств имён так же являются идентификаторами, относящимися к той области видимости, где они были объявлены. Для обращения к идентификатору id из пространства имён N используется оператор разрешения области видимости :: и запись N::id. Идентификатор id должен содержаться в этом пространстве. Для обращения к члену id глобального пространства имён (которое объявлено неявно и содержит все глобальные идентификаторы) можно использовать запись ::id.

Всегда использовать оператор :: для конкретизации области видимости может быть неудобно. Поэтому в C++ существует оператор using. С помощью объявления using namespace N; содержимое пространства имён N «включается» в текущую область видимости. При использовании конструкции using N::member; в текущую область видимости «включается» лишь член member пространства имён N. Следует иметь ввиду, что последствия применения директивы using внутри некоторой области видимости не могут быть отменены. Поэтому, в частности, не стоит использовать данную директиву во внешнем пространстве внутри заголовочных файлов, так как это может неявно повлиять на круг доступных пользователю имён, что является нежелательным.

Специальные безымянные пространства имён позволяют ограничить область видимости объявленных внутри него идентификаторов текущей единицей трансляции. Безымянные пространства имён объявляются подобно обычным, но с опущенным именем: namespace { ... }. Данная конструкция призвана устранить необходимость в использовании слова static для решения аналогичной задачи, оставив за ним лишь функцию квалификатора хранения.

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

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

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

Аргументы по умолчанию

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

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

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

На смену непосредственному использованию функций malloc и free для решения задачи выделения памяти под объект или массив объектов в C++ пришли ключевые слова new и delete, вызывающие соответствующие операторные функции для выделения памяти совместно с вызовом конструктора или деструктора объекта. Для конструирования объекта типа T в динамической (по умолчанию) области памяти следует использовать выражение new T(...), где ... означают аргументы конструктора. Необходимый объем памяти будет рассчитан автоматически, а в качестве результата будет возвращён указатель, либо сгенерировано исключение std::bad_alloc. Память можно освободит с помощью выражения delete p, где p — указатель на удаляемый объект (так же будет вызван деструктор).

Аналогично, для выделения памяти под массив размера n объектов типа T можно использовать конструкцию new T[n]. При этом для создания каждого элемента массива будет вызван конструктор по умолчанию. Для удаления массива, выделенного таким образом, следует использовать выражение delete[] p.

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

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

В стандартной библиотеки <new> языка C++ определено несколько дополнительных операторных функций для выделения и освобождения памяти. Если передать аргумент std::nothrow оператору new, то исключительная ситуация генерироваться не будет: вместо неё оператор просто вернёт нулевой указатель. Если в качестве аргумента оператору new передать указатель, то он не будет выделять память, а разместит сконструированный объект по переданному указателю.

Исключения

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

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

Механизм обработки исключений в C++ основывается на трёх операторах: try, catch и throw. Функция, которая хочет сгенерировать исключение, должна использовать для этих целей оператор throw после которого указывается объект, описывающий произошедшую исключительную ситуацию. Если где-то в блоке кода, следующем за оператором try, возникает необработанная исключительная ситуация (исключение может быть брошено любой из функций, вызываемых в процессе исполнения блока кода), осуществляется попытка найти блок кода, который будет способен её обработать. Если такой блок кода не найден, вызывается функция terminate, завершающая выполнение программы.

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

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

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

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

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

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

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

Автоматический вывод типов(C++11)

Автоматический вывод типов — новая возможность языка C++, призванная упростить процесс программирования и расширить возможности обобщённого программирования. При инициализирующем объявлении переменной в качестве её типа можно указать спецификатор auto, указывающий компилятору, что он должен вычислить тип самостоятельно на основе типа выражения, используемого для инициализации переменной. Такой подход позволяет, в том числе, повысить читаемость кода, особенно в случаях, когда формальная запись корректного типа является слишком громоздкой и сложной для восприятия. Альтернативой подходу с использованием автоматического выведения типов является объявление псевдонимов для сложных типов данных с использованием typedef.

Вместе с auto оператор decltype позволяет вычислить результирующий тип некоторого выражения, записанного в скобках после оператора, что потом может быть использовано для объявления переменных и даже функций. Новая альтернативная форма объявления функций в C++ представлена следующим синтаксисом: auto f(/* аргументы */) -> dectype(expression). Здесь результирующий тип выражения expression будет использован как тип возвращаемого значения для функции f. Такая возможность нужна далеко не всегда, но иногда является достаточно полезной. В качестве недостатка такой конструкции можно заметить возможную необходимость двухкратного использования expression: в качестве аргумента decltype и внутри тела функции.

Для объявления функций в новом стиле нет необходимости обязательно использовать decltype. Можно просто указать желаемый тип данных после символов ->: auto f(/* аргументы */) -> T. Отличие такого синтаксиса от традиционного T f(/* аргументы */) заключается в том, что в первом случае T уже относится к области видимости функции, тогда как во втором T находится в той области видимости, в которой функция объявляется. Когда возвращаемый тип находится в той же области видимости, что и функция, а объявление функции происходит в другой области видимости, новая форма записи позволяет избавиться от необходимости дополнительно использовать оператор разрешения области видимости ::, что бывает полезно.

Лямбда-выражения(C++11)

В отличие от современного C, язык C++ не позволяет вкладывать одни функции в другие. Такая практика иногда используется, когда есть потребность в создании функций, невидимых остальным, для локального использования или передачи в другие функции по указателю. В C++ понятие функций обобщается в соответствии с введением так называемых «функциональных объектов» — функторов. Функторы — это обычные с точки зрения языка объекты, подобные указателям на функции и самим функциям тем, что для них так же возможно использования оператора вызова функции (). Так, в записи f() в качестве f можно использовать функцию, указатель на функцию или функтор. Определение функтора является более громоздким, чем определение функции, и не удовлетворяет потребности в локальных, компактных, безымянных функциях или функциональных объектах. Поэтому одним из нововведений C++11 стали лямбда-выражения — особые синтаксические конструкции, конструирующие функторы безымянного типа: которые могут быть сохранены в контейнер-обёртку std::function наравне с другими функциональными объектами и указателями на функции.

Самый простая форма синтаксиса для создания безымянных функторов (замыканий) имеет следующий вид: []{}. Такая запись соответствует «пустому» функтору, который не принимает аргументов, ничего не делает и ничего не возвращает. Аргументы с их типами и именами можно указать в круглых скобках лямбда-выражения, размещённых между квадратными и фигурными. Тело функции — в фигурных скобках. Возвращаемый тип T можно указать в виде конструкции -> T, помещённой между круглыми и фигурными скобками. Квадратные скобки используются для задачи сохранения так называемого «контекста» внутри функтора. В квадратных скобках перечисляются через запятую имена переменных из области видимости, в которой используется лямбда-выражение, которые должны быть скопированы в функтор и далее могут быть переданы вместе с ним. Использование знака & перед именем переменной позволяет передать его в функтор по ссылке, а не по значению. Использование внутри квадратных скобок символа = говорит о том, что в замыкании может быть использована любая переменная из контекста, захваченная по значению, если не было указано иного. Использование символа & играет аналогичную роль, но означает захват по ссылке в качестве умолчания. В случае пустых квадратных скобок ничего из контекста не будет захвачено.