Как стать автором
Обновить

Книга «C++ для профи»

Время на прочтение24 мин
Количество просмотров18K
image Привет, Хаброжители! С++ — популярный язык для создания ПО. В руках увлеченного программиста С++ становится прекрасным инструментом для создания лаконичного, эффективного и читаемого кода, которым можно гордиться.

«C++ для профи» адресован программистам среднего и продвинутого уровней, вы продеретесь сквозь тернии к самому ядру С++. Часть 1 охватывает основы языка С++ — от типов и функций до жизненного цикла объектов и выражений. В части II представлена стандартная библиотека C ++ и библиотеки Boost. Вы узнаете о специальных вспомогательных классах, структурах данных и алгоритмах, а также о том, как управлять файловыми системами и создавать высокопроизводительные программы, которые обмениваются данными по сети.

Об этой книге


Современные программисты на C++ имеют доступ к ряду очень качественных книг, например «Эффективный современный C++» Скотта Мейерса1 и «Язык программирования C++» Бьёрна Страуструпа, 4-е издание2. Однако эти книги написаны для достаточно продвинутых программистов. Доступны также некоторые вводные тексты о C++, но они часто пропускают важные детали, потому что ориентированы на абсолютных новичков в программировании. Опытному программисту непонятно, где можно погрузиться в язык C++.

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

Кому будет интересна эта книга?


Эта книга предназначена для программистов среднего и продвинутого уровня, уже знакомых с основными концепциями программирования. Если у вас нет опыта в программировании систем, ничего страшного. Опытным программистам приложений
издание также будет полезно.

Вы познакомитесь с основными фишками современного С++:


  • Базовые типы, ссылочные и пользовательские типы.
  • Полиморфизм во время компиляции и полиморфизм во время выполнения.
  • Жизненный цикл объекта, включая длительность хранения, стек вызовов, управление памятью, исключения и парадигму RAII.
  • Продвинутые выражения, операторы и функции.
  • Умные указатели, структуры данных, дата и время, числовые данные и др.
  • Контейнеры, итераторы, строки и алгоритмы.
  • Потоки и файлы, многозадачность, сетевое программирование и разработка приложений.

Отслеживание жизненного цикла объекта


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

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

Листинг 4.5. Класс Tracer и его конструктор с деструктором

#include <cstdio>

struct Tracer {
    Tracer(const char* name1) : name{ name }2 {
       printf("%s constructed.\n", name); 3
    }
    ~Tracer() {
       printf("%s destructed.\n", name); 4
    }
private:
    const char* const name;
};

Конструктор принимает один параметр 1 и сохраняет его в члене name 2. Затем он печатает сообщение, содержащее name 3. Деструктор 4 также выводит сообщение с name.

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

Листинг 4.6. Программа, использующая класс Tracer в листинге 4.5 для иллюстрации длительности хранения

#include <cstdio>

struct Tracer {
    --пропуск--
};

static Tracer t1{ "Static variable" }; 1
thread_local Tracer t2{ "Thread-local variable" }; 2

int main() {
  const auto t2_ptr = &t2;
  printf("A\n"); 3
  Tracer t3{ "Automatic variable" }; 4
  printf("B\n");
  const auto* t4 = new Tracer{ "Dynamic variable" }; 5
  printf("C\n");
}

Листинг 4.6 содержит Tracer со статической 1, локальной поточной 2, автоматической 4 и динамической 5 длительностью хранения. Между каждой строкой в main выводится символ A, B или C для ссылки 3.

Запуск программы приводит к результату в листинге 4.7.

Листинг 4.7. Пример вывода из листинга 4.6

Static variable constructed.
Thread-local variable constructed.
A 3
Automatic variable constructed.
B
Dynamic variable constructed.
C
Automatic variable destructed.
Thread-local variable destructed.
Static variable destructed.

Перед первой строкой main 3 статические и потоковые локальные переменные t1 и t2 были инициализированы 1 2. Это можно увидеть в листинге 4.7: обе переменные напечатали свои сообщения инициализации до A. Как и для любой автоматической переменной, область видимости t3 ограничена включающей функцией main. Соответственно t3 создается в месте инициализации сразу после A.

После B вы можете видеть сообщение, соответствующее инициализации t4 5. Обратите внимание, что соответствующее сообщение, генерируемое динамическим деструктором Tracer, отсутствует. Причина в том, что вы (намеренно) потеряли память для объекта, на который указывает t4. Поскольку команды delete t4 не было, деструктор никогда не будет вызван.

Перед возвратом main выводится С. Поскольку t3 — это автоматическая переменная, область видимости которой ограничена main, на этом этапе она уничтожается, поскольку main делает возврат.

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

Исключения


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

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

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

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

Чтобы вызвать исключение, используйте ключевое слово throw, за которым следует бросаемый объект.

Большинство объектов являются бросаемыми. Однако рекомендуется использовать одно из исключений, доступных в stdlib, например std::runtime_error в заголовке <stdеxcept>. Конструктор runtime_error принимает const char* с нулевым символом в конце, описывающий природу состояния ошибки. Это сообщение можно получить с помощью метода what, который не принимает параметров.

Класс Groucho в листинге 4.8 создает исключение всякий раз при вызове метода forget с аргументом, равным 0xFACE.

Листинг 4.8. Класс Groucho

#include <stdexcept>
#include <cstdio>

struct Groucho {
   void forget(int x) {
      if (x == 0xFACE) {
         throw1 std::runtime_error2{ "I'd be glad to make an exception." };
      }
      printf("Forgot 0x%x\n", x);
    }
};

Чтобы вызвать исключение, в листинге 4.8 используется ключевое слово throw 1, за которым следует объект std::runtime_error 2.

Использование блоков try-catch

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

Листинг 4.9 показывает использование блока try-catch для обработки исключений, генерируемых объектом Groucho.

В методе main создается объект Groucho, а затем устанавливается блок try-catch 1. В части try вызывается метод forget класса groucho с несколькими различными параметрами: 0xC0DE 2, 0xFACE 3 и 0xC0FFEE 4. Внутри части catch обрабатываются любые исключения std::runtime_error 5, выводя сообщение в консоли 6.

Листинг 4.9. Использование try-catch для обработки исключений класса Groucho

#include <stdexcept>
#include <cstdio>

struct Groucho {
      --пропуск--
};

int main() {
   Groucho groucho;
   try { 1
       groucho.forget(0xC0DE); 2
       groucho.forget(0xFACE); 3
       groucho.forget(0xC0FFEE); 4
    } catch (const std::runtime_error& e5) {
       printf("exception caught with message: %s\n", e.what()); 6
    }
}

При запуске программы в листинге 4.9 вы получите следующий вывод:

Forgot 0xc0de
exception caught with message: I'd be glad to make an exception.

При вызове forget с параметром 0xC0DE 2 groucho выводит Forgot0xc0de и завершает выполнение. При вызове forget с параметром 0xFACE 3 groucho выдает исключение. Это исключение остановило нормальное выполнение программы, поэтому forget никогда больше не вызывается 4. Вместо этого исключение в полете перехватывается 5, а его сообщение выводится в консоль 6.

Классы исключений stdlib

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

Стандартные классы исключений

stdlib предоставляет стандартные классы исключений в заголовке <stdеxcept>. Они должны стать вашим первым причалом при программировании исключений. Суперклассом для всех стандартных классов исключений является класс std::exception. Все подклассы в std::exception могут быть разделены на три группы: логические ошибки (logic_error), ошибки выполнения (runtime_error) и ошибки языковой поддержки. Ошибки языковой поддержки обычно не относятся к вам как к программисту, но вы наверняка столкнетесь с логическими ошибками и ошибками выполнения. Рисунок 4.1 обобщает их отношения.

image


Краткий курс по наследованию


Прежде чем вводить исключения stdlib, нужно понять простое наследование классов C++ на очень высоком уровне. Классы могут иметь подклассы, которые наследуют функциональность своих суперклассов. Синтаксис в листинге 4.10 определяет это отношение.

Листинг 4.10. Определение суперклассов и подклассов

struct Superclass {
    int x;
};

struct Subclass : Superclass { 1
    int y;
    int foo() {
      return x + y; 2
    }
};

В Superclass нет ничего особенного. Но вот объявление Subclass 1 является особенным. Оно определяет отношения наследования с использованием синтаксиса: Superclass. Subclass наследует члены от Superclass, которые не помечены как private. Это можно увидеть в действии, когда Subclass использует поле x 2. Это поле принадлежит Superclass, но поскольку Subclass наследует от Superclass, x доступно.

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

Логические ошибки

Логические ошибки происходят из класса logic_error. Как правило, можно избежать эти исключения путем более тщательного программирования. Основной пример — логическое предусловие класса не выполняется, например, когда инвариант класса не может быть установлен. (Вспомните из главы 2, что инвариант класса — это особенность класса, которая всегда верна.)

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

logic_error имеет несколько подклассов, о которых следует знать:

  • domain_error сообщает об ошибках, связанных с допустимым диапазоном ввода, особенно для математических функций. Например, квадратный корень поддерживает только неотрицательные числа (в реальном случае). Если передается отрицательный аргумент, функция квадратного корня может выдать domain_error.
  • Исключение invalid_argument сообщает, как правило, о неожиданных параметрах.
  • Исключение length_error сообщает, что какое-либо действие нарушит ограничение максимального размера.
  • Исключение out_of_range сообщает, что некоторое значение не находится в ожидаемом диапазоне. Каноническим примером является индексирование с проверкой границ в структуре данных.

Ошибки выполнения

Ошибки выполнения происходят из класса runtime_error. Эти исключения помогают сообщать об ошибках, которые выходят за рамки программы. Как и logic_error, runtime_error имеет несколько подклассов, которые могут оказаться полезными:

  • system_error сообщает, что операционная система обнаружила некоторую ошибку. Такого рода исключения могут тысячи раз встретиться на вашем пути. Внутри заголовка <system_error> находится большое количество кодов ошибок и их состояний. Когда создается system_error, информация об ошибке упаковывается, чтобы можно было определить природу ошибки. Метод .code() возвращает enumclass типа std::errc, который имеет большое количество значений, таких как bad_file_descriptor, timed_out и license_denied,
  • overflow_error и underflow_error сообщают об арифметическом переполнении и потере значимости соответственно.

Другие ошибки наследуются напрямую от exception. Распространенным является исключение bad_alloc, которое сообщает, что new не удалось выделить необходимую память для динамического хранения.

Ошибки языковой поддержки

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

Обработка исключений

Правила обработки исключений основаны на наследовании классов. Когда выбрасывается исключение, блок catch обрабатывает его, если тип выброшенного исключения соответствует типу исключения обработчика или если тип выброшенного исключения наследуется от типа исключения обработчика.

Например, следующий обработчик перехватывает любое исключение, которое наследуется от std::exception, включая std::logic_error:

try {
   throw std::logic_error{ "It's not about who wrong "
                          "it's not about who right" };
} catch (std::exception& ex) {
   // Обрабатывает std::logic_error. Поскольку он наследуется от std::exception
}

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

try {
  throw 'z'; // Don't do this.
} catch (...) {
  // Обрабатывает любое исключение, даже 'z'
}

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

Можно обрабатывать различные типы исключений, происходящих из одного и того же блока try, объединяя операторы catch, как показано здесь:

try {
  // Код, который может вызвать исключение
  --пропуск--
} catch (const std::logic_error& ex) {
  // Запись исключения и завершение работы программы; найдена программная ошибка!
  --пропуск--
} catch (const std::runtime_error& ex) {
  // Делаем все, что можно
  --пропуск--
} catch (const std::exception& ex) {
  // Обработка любого исключения, наследуемого от std:exception,
  // которое не является logic_error или runtime_error.
  --пропуск--
} catch (...) {
  // Паника; было сгенерировано непредвиденное исключение
  --пропуск--
}

Обычно такой код можно увидеть в точке входа в программу.

Перебрасывание исключения


В блоке catch можно использовать ключевое слово throw, чтобы возобновить поиск подходящего обработчика исключений. Это называется перебрасыванием исключения. Есть несколько необычных, но важных случаев, когда вы, возможно, захотите дополнительно проверить исключение, прежде чем обработать его, как показано в листинге 4.11.

Листинг 4.11. Перебрасывание ошибки

try {
  // Код, который может вызвать system_error
  --пропуск--
} catch(const std::system_error& ex) {
   if(ex.code()!= std::errc::permission_denied){
   // Ошибка, не связанная с отказом в доступе
     throw; 1
}
  // Восстановление после ошибки
   --пропуск--
}

В этом примере код, который может выдать system_error, помещается в блок trycatch.

Все системные ошибки обрабатываются, но, если это не ошибка EACCES (в доступе отказано), исключение перебрасывается 1. У этого подхода есть некоторые потери производительности, и полученный код часто оказывается излишне запутанным.

Вместо повторной обработки можно определить новый тип исключения и создать отдельный обработчик перехвата для ошибки EACCES, как показано в листинге 4.12.

Листинг 4.12. Перехват конкретного исключения, но не перебрасывание


try {
  // Генерация исключения PermissionDenied
  --пропуск--
} catch(const PermissionDenied& ex) {
  // Восстановление после ошибки EACCES (отказано в доступе) 1
  --пропуск--
}

Если генерируется std::system_error, обработчик PermissionDenied 1 не поймает его. (Конечно, обработчик std::system_error все равно можно оставить, чтобы перехватывать такие исключения, если это необходимо.)

Пользовательские исключения

Программист может при необходимости определить свои собственные исключения; обычно эти пользовательские исключения наследуются от std::exception. Все классы из stdlib используют исключения, которые происходят от std::exception. Это позволяет легко перехватывать все исключения, будь то из вашего кода или из stdlib, с помощью одного блока catch.

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

Ключевое слово noexcept — еще один термин, связанный с исключениями, который следует знать. Можно и нужно пометить любую функцию, которая в теории не может вызвать исключение, ключевым словом noexcept, как показано ниже:

bool is_odd(int x) noexcept {
  return 1 == (x % 2);
}

Функции с пометкой noexcept составляют жесткий контракт. При использовании функции, помеченной как noexcept, вы можете быть уверены, что функция не может вызвать исключение. В обмен на это вы должны быть предельно осторожны, когда помечаете собственную функцию как noexcept, так как компилятор не может это проверить. Если код выдает исключение внутри функции, помеченной как noexcept, это плохо. Среда выполнения C++ вызовет функцию std::terminate, которая по умолчанию завершит работу программы через abort. После такого программа не может быть восстановлена:

void hari_kari() noexcept {
   throw std::runtime_error{ "Goodbye, cruel world." };
}

Пометка функции ключевым словом noexcept позволяет оптимизировать код, полагаясь на то, что функция не может вызвать исключение. По сути, компилятор освобождается для использования семантики переноса, что может быть выполнено быстрее (подробнее об этом в разделе «Семантика перемещения», с. 184).

Примечание


Ознакомьтесь с правилом 14 «Эффективного использования C++» Скотта Мейерса, чтобы подробно обсудить noexcept. Суть в том, что некоторые конструкторы переноса и операторы присваивания переноса могут выдавать исключение, например если им нужно выделить память, а система не работает. Если конструктор переноса или оператор присваивания переноса не указывает иное, компилятор должен предполагать, что перенос может вызвать исключение. Это отключает определенные оптимизации.

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

Стеки

Стек — это гибкий контейнер данных, который может содержать динамическое количество элементов. Существуют две основные операции, которые поддерживаются всеми стеками: вставка элементов в верхнюю часть стека и удаление этих элементов. Эта структура данных организована по принципу «последним пришел — первым вышел», как показано на рис. 4.2.

image

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

Стеки вызовов и обработка исключений

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

Выбрасывание исключений из деструктора

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

Допустим, генерируется исключение и во время размотки стека другое исключение генерируется деструктором во время обычной очистки. Теперь у вас есть два исключения в состоянии полета. Как среда выполнения C++ должна справляться с такой ситуацией?

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

Листинг 4.13. Программа, где показана опасность создания исключения в деструкторе

#include <cstdio>
#include <stdexcept>

struct CyberdyneSeries800 {
  CyberdyneSeries800() {
   printf("I'm a friend of Sarah Connor."); 1
  }
  ~CyberdyneSeries800() {
    throw std::runtime_error{ "I'll be back." }; 2
}
};

  int main() {
    try {
      CyberdyneSeries800 t800; 3
      thro std::runtime_error{ "Come with me if you want to live." }; 4
    } catch(const std::exception& e) { 5
      printf("Caught exception: %s\n", e.what()); 6
    }
}
----------------------------------------------------------------------
I'm a friend of Sarah Connor. 

Примечание


Листинг 4.13 вызывает std::terminate, поэтому в зависимости от операционной среды может быть показано всплывающее окно с уведомлением.

Во-первых, был объявлен класс CyberdyneSeries800, который имеет простой конструктор, который выводит сообщение 1, и воинственный деструктор, который генерирует необработанное исключение 2. В main определяется блок try, в котором инициализируется CyberdyneSeries800 под именем t800 3, и выбрасывается runtime_error 4. В лучшем случае блок catch 5 обработает это исключение, выведет его сообщение 6 и все выйдет изящно. Поскольку t800 — это автоматическая переменная в блоке try, она разрушается во время обычного процесса поиска обработчика для исключения, которое было выброшено 4. А поскольку t800 создает исключение в своем деструкторе 2, программа вызывает std::terminate и внезапно завершается.

Как правило, обращайтесь с деструкторами так, как если бы они были noexcept.

Класс SimpleString


Используя расширенный пример, давайте рассмотрим, как конструкторы, деструкторы, члены и исключения объединяются. Класс SimpleString в листинге 4.14 позволяет добавлять строки в стиле C и выводить результат.

Листинг 4.14. Конструктор и деструктор класса SimpleString

#include <stdexcept>

struct SimpleString {
  SimpleString(size_t max_size) 1
    : max_size{ max_size }, 2
      length{} { 3
    if (max_size == 0) {
      throw std::runtime_error{ "Max size must be at least 1." }; 4
    }
    buffer = new char[max_size]; 5
    buffer[0] = 0; 6
    }

   ~SimpleString() {
     delete[] buffer; 7
    }
--пропуск--
private:
    size_t max_size;
    char* buffer;
    size_t length;
};

Конструктор 1 принимает один параметр max_size. Это максимальная длина строки, которая включает символ завершения строки. Инициализатор члена 2 сохраняет эту длину в переменной-члене max_size. Это значение также используется в выражении new массива для выделения буфера для хранения данной строки 5. Полученный указатель сохраняется в buffer. Длина инициализируется нулем 3, и это гарантирует, что по крайней мере буфер будет достаточного размера для хранения нулевого байта 4. Поскольку строка изначально пуста, первый байт буфера заполняется нулем 6.

Примечание


Поскольку max_size — это size_t, он не имеет знака и не может быть отрицательным, поэтому не нужно проверять это фиктивное условие.

Класс SimpleString владеет ресурсом — памятью, на которую указывает буфер, — которая должна быть освобождена при прекращении использования. Деструктор содержит одну строку 7, которая освобождает buffer. Поскольку распределение и освобождение buffer связаны конструктором и деструктором SimpleString, память никогда не будет потеряна.

Этот шаблон называется «получение ресурса есть инициализация» (RAII), или «получение конструктора — освобождение деструктора» (CADRe).

Примечание


Класс SimpleString все еще имеет неявно определенный конструктор копирования. Несмотря на то что память не может быть потеряна, при копировании класс потенциально освободится вдвое. Вы узнаете о конструкторах копирования в разделе«Семантике копирования», с. 176. Просто знайте, что листинг 4.14 — это обучающий инструмент, а не рабочий код.

Добавление и вывод

Класс SimpleString пока не очень полезен. В листинг 4.15 добавлена возможность выводить строку и добавлять набор символов в конец строки.

Листинг 4.15. Методы print и append_line для SimpleString

#include <cstdio>
#include <cstring>
#include <stdexcept>

struct SimpleString {
  --пропуск--
  void print(const char* tag) const { 1
    printf("%s: %s", tag, buffer);
  }
  bool append_line(const char* x) { 2
  const auto x_len = strlen3(x);
  if (x_len + length + 2 > max_size) return false; 4
  std::strncpy(buffer + length, x, max_size - length);
  length += x_len;
  buffer[length++] = '\n';
  buffer[length] = 0;
  return true;
 }
 --пропуск--
};

Первый метод print 1 выводит строку. Для удобства можно предоставить строку tag, чтобы можно было сопоставить вызов print с результатом. Этот метод является постоянным, потому что нет необходимости изменять состояние SimpleString.

Метод append_line 2 принимает строку с нулем в конце и добавляет ее содержимое — плюс символ новой строки — в buffer. Он возвращает true, если был успешно добавлен, и false, если не было достаточно места. Во-первых, append_line должен определить длину x. Для этого используется функция strlen 3 из заголовка <сstring>, которая принимает строку с нулевым символом в конце и возвращает ее длину:

size_t strlen(const char* str);

strlen используется для вычисления длины x и инициализации x_len с результатом. Этот результат используется для вычисления того, приведет ли добавление x (символов новой строки) и нулевого байта к текущей строке к получению строки с длиной, превышающей max_size 4. Если это так, append_line возвращает false.

Если для добавления x достаточно места, необходимо скопировать его байты в правильное место в buffer. Функция std::strncpy 5 из заголовка <сstring> является одним из подходящих инструментов для этой работы. Она принимает три параметра: адрес назначения, адрес источника и количество символов для копирования:

char* std::strncpy(char* destination, const char* source, std::size_t num);

Функция strncpy будет копировать до num байтов из source в destination. После завершения она вернет значение destination (которое будет отброшено).

После добавления количества байтов x_len, скопированных в buffer, к length работа завершается добавлением символа новой строки \n и нулевого байта в конец buffer. Функция возвращает true, чтобы указать, что введенный х был успешно добавлен в виде строки в конец буфера.

Предупреждение


Используйте strncpy очень осторожно. Слишком легко забыть символ конца строки в исходной строке или не выделить достаточно места в целевой строке. Обе ошибки приведут к неопределенному поведению. Мы рассмотрим более безопасную альтернативу во второй части книги.

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

Листинг 4.16 показывает пример использования SimpleString, где добавляются несколько строк и промежуточные результаты выводятся в консоль.

Листинг 4.16. Методы SimpleString

#include <cstdio>
#include <cstring>
#include <exception>

struct SimpleString {
   --пропуск--
}
int main() {
   SimpleString string{ 115 }; 1
   string.append_line("Starbuck, whaddya hear?");
   string.append_line("Nothin' but the rain."); 2
   string.print("A"); 3
   string.append_line("Grab your gun and bring the cat in.");
   string.append_line("Aye-aye sir, coming home."); 4
   string.print("B"); 5
   if (!string.append_line("Galactica!")) { 6
      printf("String was not big enough to append another message."); 7
   }
}

Сначала создается SimpleString с max_length=115 1. Метод append_line используется дважды 2, чтобы добавить некоторые данные в строку, а затем вывести содержимое вместе с тегом A 3. Затем добавляется больше текста 4 и снова выводится содержимое, на этот раз с тегом B 5. Когда append_line определяет, что SimpleString исчерпал свободное пространство 6, возвращается false 7. (Вы как пользователь SimpleString несете ответственность за проверку этого условия.)

Листинг 4.17 содержит выходные данные запуска этой программы.

Листинг 4.17. Результат выполнения программы в листинге 4.16

A: Starbuck, whaddya hear? 1
Nothin' but the rain.
B: Starbuck, whaddya hear? 2
Nothin' but the rain.
Grab your gun and bring the cat in.
Aye-aye sir, coming home.
String was not big enough to append another message. 3

Как и ожидалось, строка содержит Starbuck, whaddya hear?\nNothin' but the rain.\nвA 1. (Вспомните из главы 2, что \n — это специальный символ новой строки.) После добавления Grab your gun and bring the cat in. и Aye-aye sir, coming home. вы получите ожидаемый результат в B 2.

Когда листинг 4.17 пытается добавить Galactica! в string, append_line возвращает false, поскольку в buffer недостаточно места. Это вызывает вывод сообщения String was not big enough to append another message 3.

Составление SimpleString

Рассмотрим, что происходит при определении класса с членом SimpleString, как показано в листинге 4.18.

Как предполагает инициализатор члена 1, string полностью построена, и ее инварианты класса назначаются после выполнения конструктора SimpleStringOwner. Здесь демонстрируется порядок членов объекта во время создания: члены создаются перед вызовом конструктора окружающего объекта. Смысл есть, а иначе как можно установить инварианты класса без знаний об инвариантах его членов?

Листинг 4.18. Реализация SimpleStringOwner

#include <stdexcept>

struct SimpleStringOwner {
   SimpleStringOwner(const char* x)
     : string{ 10 } { 1
     if (!string.append_line(x)) {
       throw std::runtime_error{ "Not enough memory!" };
    }
    string.print("Constructed");
  }
  ~SimpleStringOwner() {
    string.print("About to destroy"); 2
  }
private:
  SimpleString string;
};

Деструкторы работают в обратном порядке. Внутри ~SimpleStringOwner() 2 нужно хранить инварианты класса строки, чтобы можно было напечатать ее содержимое. Все члены уничтожаются после вызова деструктора объекта.


В листинге 4.19 используется SimpleStringOwner.

Листинг 4.19. Программа, содержащая SimpleStringOwner

--пропуск--
int main() {
   SimpleStringOwner x{ "x" };
   printf("x is alive\n");
}
--------------------------------------------------------------------
Constructed: х 1
x is alive
About to destroy: х 2

Как и ожидалось, член string в x 1 создается надлежащим образом, потому что конструкторы членов объекта вызываются перед конструктором объекта, в результате чего появляется сообщение Constructed: x. Как автоматическая переменная x уничтожается непосредственно перед выходом из main, и вы получаете сообщение About to destroy: x 2. Член string все еще доступен в этот момент, потому что деструкторы членов вызываются после деструктора вмещающего объекта.

Размотка стека вызовов

Листинг 4.20 демонстрирует, как обработка исключений и размотка стека работают вместе. Блок try-catch устанавливается в main, после чего выполняется серия вызовов функций. Один из этих вызовов вызывает исключение.

Листинг 4.20. Программа, где используется SimpleStringOwner и размотка стека вызовов

--пропуск--
void fn_c() {
   SimpleStringOwner c{ "cccccccccc" }; 1
}

void fn_b() {
  SimpleStringOwner b{ "b" };
  fn_c(); 2
}

int main() {
  try { 3
   SimpleStringOwner a{ "a" };
   fn_b(); 4
   SimpleStringOwner d{ "d" }; 5
 } catch(const std::exception& e) { 6
  printf("Exception: %s\n", e.what());
 }
}

В листинге 4.21 показаны результаты запуска программы из листинга 4.20.

Листинг 4.21. Результат запуска программы из листинга 4.20

Constructed: a
Constructed: b
About to destroy: b
About to destroy: a
Exception: Not enough memory!

Вы установили блок try-catch 3. Первый экземпляр SimpleStringOwner, a, создается без инцидентов, и в консоль выводится сообщение Constructed: а. Далее вызывается fn_b 4. Обратите внимание, что вы все еще находитесь в блоке try-catch, поэтому любое выброшенное исключение будет обработано. Внутри fn_b другой экземпляр SimpleStringOwner, b, успешно создается, и Constructed: b выводится на консоль. Затем происходит вызов еще одной функции, fn_c 2.

Давайте на минуту остановимся, чтобы разобраться, как выглядит стек вызовов, какие объекты живы и как выглядит ситуация обработки исключений. Сейчас у нас есть два живых и действительных объекта SimpleStringOwner: a и b. Стек вызовов выглядит как main() → fn_ () → fn_c(), и в main настроен обработчик исключений для обработки любых исключений. Эта ситуация показана на рис. 4.3.

В 1 возникает небольшая проблема. Напомним, что SimpleStringOwner имеет член SimpleString, который всегда инициализируется с max_size 10. При попытке создания c конструктор SimpleStringOwner выдает исключение, потому что вы пытались добавить «cccccccccc», который имеет длину 10, что выходит за рамки, потому что нужно еще добавить символы новой строки и завершения строки.

Теперь в полете находится одно исключение. Стек будет раскручиваться до тех пор, пока не будет найден соответствующий обработчик, и все объекты, выпадающие из области видимости в результате этого раскручивания, будут уничтожены. Обработчик доходит до стека 6, поэтому fn_c и fn_b разматываются. Поскольку SimpleStringOwner b — это автоматическая переменная в fn_b, она разрушается и в консоль выводится сообщение About to destroy: b. После fn_b автоматические переменные внутри try {} уничтожаются. Это включает в себя SimpleStringOwner a, поэтому в консоль выводится About to destroy: a.

image

Как только исключение происходит в блоке try{}, дальнейшие операторы не выполняются. В результате d никогда не инициализируется 5 и конструктор d не вызывается и не выводится в консоль. После размотки стека вызовов выполнение сразу переходит к блоку catch. В итоге в консоль выводится сообщение Exception: Not enough memory! 6.

Исключения и производительность

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

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

Альтернативы для исключений

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

struct HumptyDumpty {
   HumptyDumpty();
   bool is_together_again();
  --пропуск--
};

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

bool send_kings_horses_and_men() {
  HumptyDumpty hd{};
  if (hd.is_together_again()) return false;
  // Использование инвариантов класса hd гарантировано.
  // HumptyDumpty с треском проваливается.
  --пропуск--
  return true;
}

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

Листинг 4.22. Фрагмент кода с объявлением структурированной привязки

struct Result { 1
   HumptyDumpty hd;
   bool success;
   };

  Result make_humpty() { 2
    HumptyDumpty hd{};
    bool is_valid;
    // Проверка правильности hd и установка соответствующего значения is_valid
    return { hd, is_valid };
   }

bool send_kings_horses_and_men() {
   auto [hd, success] = make_humpty(); 
   if(!success) return false;
   // Установка инвариантов класса
   --пропуск--
   return true;
}

Сначала объявляется POD, который содержит HumptyDumpty и флаг success 1. Затем определяется функция make_humpty 2, которая создает и проверяет HumptyDumpty. Такие методы называются фабричными, поскольку их целью является инициализация объектов. Функция make_humpty оборачивает его и флаг success в Result при возврате. Синтаксис в точке вызова 3 показывает, как можно распаковать Result, получив несколько переменных с определением типа при помощи auto.

Примечание


Более подробное описание структурированных привязок приведено в подразделе «Структурированные привязки», с. 289.


Об авторе


Джош Лоспинозо (Josh Lospinoso) — доктор философии и предприниматель, прослуживший 15 лет в армии США. Джош — офицер, занимающийся вопросами кибербезопасности. Написал десятки программ для средств информационной безопасности и преподавал C++ начинающим разработчикам. Выступает на различных конференциях, является автором более 20 рецензируемых статей и стипендиатом Родса, а также имеет патент. В 2012 году стал соучредителем успешной охранной компании. Джош ведет блог и активно участвует в разработке ПО с открытым исходным кодом.

О научном редакторе


Кайл Уиллмон (Kyle Willmon) — разработчик информационных систем с 12-летним опытом в C++. В течение 7 лет работал в сообществе по информационной безопасности, используя C++, Python и Go в различных проектах. В настоящее время является разработчиком в команде Sony Global Threat Emulation.

Более подробно с книгой можно ознакомиться на сайте издательства
» Оглавление
» Отрывок

Для Хаброжителей скидка 25% по купону — C++

По факту оплаты бумажной версии книги на e-mail высылается электронная книга.
Теги:
Хабы:
Всего голосов 8: ↑6 и ↓2+6
Комментарии17

Публикации

Информация

Сайт
piter.com
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия