[Перевод] Безопасность типов и ресурсов в современном C++
1. Введение
Полная безопасность типов и ресурсов была идеалом (целью) C++ с самого начала (1979) и достижима благодаря осмысленной технике программирования, использующей правила кодирования и статический анализ. Базовая модель для достижения этой цели приведена в [Str»15], и она не предусматривает ограничений того, что можно выразить в коде, или накладных расходов времени выполнения, если сравнивать ее с традиционными методами программирования на C и C++. Основные методы проектирования и реализации просто обеспечивают:
§2: доступ к каждому объекту осуществляется в соответствии с типом, с которым он был определен
§3: каждый объект должным образом конструируется и уничтожается
§4–5: каждый указатель либо указывает на корректный объект, либо является nullptr
§6: каждая ссылка на указатель не является ссылкой на nullptr (часто это проверка во время выполнения)
§6: любой доступ через указатель по индексу находится в пределах диапазона (часто это проверка во время выполнения)
Это именно то, что требует C++ и что большинство программистов пытались соблюдать с незапамятных времен. Сложность заключается в том, чтобы гарантировать это в реальных программах. Опыт показывает, что это невозможно сделать без статического анализа и поддержки во время выполнения. Более того, по фундаментальным причинам это невозможно сделать даже при такой поддержке, если допускаются различные легальные конструкции языка для поддержки высокой производительности.
Выходом из этой дилеммы является тщательно разработанный набор правил кодирования, поддерживаемый библиотечными средствами и обеспечиваемый статическим анализом.
Эта презентация основана на Основных Рекомендациях (C++ Core Guidelines) [ОР] и правилах их применения (например, реализованных в средстве проверки Основых Рекомендаций, распространяемого вместе с Microsoft Visual Studio). То есть изложенные здесь положения подкреплены конкретными правилами и поддерживаются существующим программным обеспечением. По умолчанию ОР не обеспечивают полную безопасность типов и ресурсов. Этот раздел представляет собой высокоуровневый обзор правил, которые должны соблюдаться, чтобы гарантировать это. Подробности можно найти в другом месте (§9).
Весь статический анализ для ОР является локальным. Нелокальный статический анализ, например, анализ всей программы, не масштабируется и, как правило, не может работать с динамической линковкой.
Чтобы соответствовать общепринятым определениям безопасности, нам также необходимо рассмотреть следующие задачи
Они рассматриваются отдельно в ОР и в других местах.
Основные Рекомендации предназначены для выборочного и постепенного внедрения. Следовательно, в тех случая, когда переход на более строгие правила не представляются целесообразным, можно использовать все традиционные приемы С++. В частности, ничто не должно препятствовать возможности C++ напрямую манипулировать аппаратными средствами там и тогда, где это необходимо.
2. Доступ к объектам
Доступ к каждому объекту осуществляется в соответствии с типом, с которым он был определен.
Язык гарантирует это, за исключением предотвратимых случаев некорректного использования указателей (см. § 4, § 5), явных приведений и смешивания типов с использованием объединений. В ОР есть специальные правила для обеспечения соблюдения этих правил языка.
Статический анализ может предотвратить небезопасное приведение и небезопасное использование объединений. Доступны типобезопасные альтернативы объединениям, такие как std: variant. Приведение необходимо только для преобразования нетипизированных данных (байтов) в типизированные объекты.
3. Конструирование и уничтожение
Каждый объект должным образом конструируется и уничтожается.
Статический анализ легко предотвращает создание неинициализированных объектов. Буферы неинициализированных беззнаковых символов допустимы в соответствии с определением языка и необходимы по соображениям производительности.
Язык гарантирует, что деструкторы для объектов вызываются при выходе из области видимости и что деструкторы для статических объектов вызываются при завершении программы.
С помощью устранения копирования (copy elision) или операций перемещения объекты можно безопасно перемещать между областями видимости. ОР требуют, чтобы перемещенный (прим. перев.: moved‑from object — объект, который перемещен в другой, но находится в валидном состоянии) объект был присваиваемым, но остальные операции над перемещенными объектами не разрешаются. Это обеспечивается с помощью статического анализа.
Объекты, которые должны быть получены и позже освобождены для какой‑либо другой части системы (например, память или файловые дескрипторы), называются ресурсами и представляются в виде объектов с деструкторами, выполняющими освобождение, и часто с конструкторами, которые выполняют получение как часть установления инварианта. Это часто называют безопасностью ресурсов или RAII (получение ресурсов есть инициализация). В дополнение к безопасности ресурсов, такое управление ресурсами на основе области видимости обеспечивает предсказуемость и сводит к минимуму удержание ресурсов.
4. Отсутствие висячих указателей
Каждый указатель либо указывает на объект, либо является nullptr. Первым и важным шагом для обеспечения этого является гарантированная инициализация (см. §3).
В этом разделе:
понятие «указатель» включает в себя все способы обращения к объекту, включая контейнеры указателей, ссылки, лямбда-захваты и умные указатели.
понятие «возврат» включает в себя все способы получения значения указателя из области видимости, включая контейнеры указателей, ссылочные аргументы, указатели на указатели, лямбда-перехваты, глобальные переменные и значения исключений.
4.1 Escape-указатели
Ни один указатель не может указывать на объект после того, как объект вышел из области видимости. Это достигается с помощью статического анализа, не разрешающего выход указателя из области видимости, в которой находится объект, на который указывает данный указатель. Значение указателя может быть возвращено из области видимости при условии
(1) Он был передан в область видимости (например, в качестве аргумента или получен от внешнего по отношению к области видимости объекта).
(2) Он указывает на объект, внешний по отношению к области видимости (например, он был инициализирован new).
Если статический анализ не может это доказать, то указатель тогда не может быть возвращен. Это подразумевает ограничения на сложность потока управления, приводящего к возвращению значения указателя
4.2 Инвалидация
Ни один указатель не должен обращаться к удаленному объекту. Очень легко запретить доступ к удаленному объекту в области видимости, в которой он был создан с использованием new (и вложенных в него областей видимости). Как и для обнаружения escape-указателей, это подразумевает ограничения на сложность потока управления, приводящего к возвращению значения указателя.
Это сохраняет проблему предотвращения удаления в вызываемой функции указателя на объект в свободном хранилище, и последующего доступа к нему в его исходной области видимости. В принципе, с этим можно было бы справиться с помощью статического анализа, но глобальный статический анализ непрактичен или недоступен во многих контекстах, поэтому ОР прибегают к аннотации:
Указатель, возвращаемый new, является владельцем и должен быть удален (если только он не хранится в статическом хранилище, гарантирующем, что он живет «вечно».).
Удалить можно только тот указатель, о котором известно, что он является владельцем. Таким образом, указатель, переданный в область видимости как owner
должен быть удален в этой области или передан в другую область как владелец. Указатель, который передается в область видимости в виде простого T*, не может быть удален. Указатель, переданный в другую область видимости как владелец и не переданный обратно в качестве владельца, считается недействительным и не может быть снова использован в своей исходной области (поскольку он будет удален).
Все, что имеет владельца, подчиняется правилам владельца. При наличии аннотации владельца, эти правила применяются с помощью статического анализа.
Аннотация владельца необходима только для низкоуровневого кода, реализующего высокоуровневые абстракции (такие как vector), и для указателей в интерфейсах, которые нельзя изменить (например, по причинам ABI). ОР рекомендуют отдавать предпочтение абстракциям более высокого уровня, таким как vector и unique_ptr, и по возможности избегать явных аннотаций владельца
4.3 «Странные» указатели
Необходимо избегать доступа через указатель «за последним элементом» (например, итератор, возвращаемый из find ()) и избегать все кроме присваиваний перемещаемым объектам. Это достигается с помощью статического анализа, следящим за правильностью использования. Типы not_end и похожий not_null, могут быть полезными для помощи статическому анализатору в случаях, когда результат x.find () и т.п. не сразу проверяется на соответствие x.end ().
5. Пулы памяти
В §4 предполагается, что объекты являются статическими, локальными (автоматическими) или находятся в свободном хранилище (куча, динамическая память), управляемом new и delete. Однако управление памятью, определяемое пользователем, в различных формах имеет важное значение во многих прикладных областях и является основополагающим в стандартной библиотеке C++.
Под пулом памяти я подразумеваю раздел памяти, в котором может храниться объект. В принципе, с указателем на объект в пуле памяти можно работать также, как с указателем на объект, созданный с помощью new. Однако в C++ отсутствует стандартная абстракция «пула». Вместо этого существуют тысячи вариаций этой идеи, что серьезно усложняет задачу авторов статических анализаторов.
Чтобы избежать висячих указателей на свои хранимые объекты, пул может применить одну из альтернативных стратегий:
Запрещать удаление или перемещение объектов.
Запрещать escape-указатели на объекты.
Инвалидировать все указатели на объекты, если вызывается операция с потенциальным удалением или перемещением.
std: vector с произвольным доступом и resize () является типичным примером пула, который требует особого внимания и устраняется путем инвалидации (третья альтернатива), применяемого статическим анализом. Если для вектора вызывается неконстантная функция, все указатели на его элементы считаются невалидными и не могут быть использованы. Это обеспечивается с помощью статического анализа. Это консервативная, но безопасная стратегия, которая может быть применена к любому пулу. Чтобы неконстантная функция (например, vector: operator[]) не считалась инвалидируемой, мы могли бы добавить аннотацию [[not_invalidate]]. Такая аннотация может быть проверена с помощью статического анализа.
6. Отсутствие ошибок диапазона
Каждая ссылка через указатель не является ссылкой на nullptr (часто это проверка во время выполнения).
ОР просто запрещает доступ через указатель, о котором неизвестно, что он не является nullptr. В качестве альтернативы многократным проверкам на nullptr предлагается тип gsl: not_null.
Любой доступ к массиву должен выполняться в пределах диапазона (часто это проверка во время выполнения).
ОР просто запрещают индексирование указателей (и эквивалентную адресную арифметику). В качестве альтернативы он предлагает gsl: span, который обеспечивает доступ с проверкой диапазона (версия gsl: span теперь std: span).
Контейнеры, range-for и алгоритмы, в отличии от кода в стиле C, значительно снижают потребность в указателях с произвольным доступом. Span-ы идеальны при использовании в интерфейсах, но их также можно использовать и локально в качестве альтернативы прямому использованию указателей, передаваемых через потенциально небезопасные интерфейсы; такие указатели обычно требуют особого (см. §7) внимания или проверки во время выполнения.
7. Низкоуровневый код
C++ широко используется для низкоуровневых манипуляций с памятью и другими системными ресурсами. Сделать C++ безопасным, исключив прямой доступ к «сырой» памяти — это не вариант. Языки, которые запрещают такой небезопасный доступ, обычно имеют способы разрешающие небезопасный код или делегируют такие манипуляции коду, написанному на C или C++.
Текущее решение для подобного низкоуровневого кода (например, для менеджера памяти, где необходимы приведения и манипулирование указателями, или для высокооптимизированной реализации ключевых структур данных) заключается в выборочном применении статического анализа. Возможно, нам понадобится понятие «доверенный код», отмеченное в самом коде, возможно, обозначенное аннотацией [[trusted]]. Такая аннотация позволила бы программистам обнаруживать [[trusted]] код независимо от настроек статического анализатора. Естественно, что код [[trusted]] потребует значительного дополнительного внимания и проверок; его следует свести к минимуму. Вызовы другого кода из [[trusted]] кода будут также считаться cтатическим анализатором корректными.
Такая аннотация не обязательно должна соответствовать «все или ничего». Параметры «profile», используемые в настоящее время для управления статическим анализом ОР, могли бы стать хорошим начальным набором опций, например, [[trusted lifetime]] подавлял бы проверку на наличие утечек и т.д.
Для большинства программ, написанных на современном C++, соблюдение ограничений, необходимых для обеспечения безопасности типов и ресурсов, не требует серьезных структурных изменений или накладных расходов во время выполнения. В старом коде потребуется заменить использование массивов через указатели абстракциями, такими как vector и span. Однако существуют структуры, которые нелегко заменить на такие гарантирующие типы. Примером могут служить обобщенные графы с узлами, в которых принадлежность и время жизни явно указаны, так что безопасность типов и ресурсов зависит от сообразительности программиста. Одним из возможных решений является четкое и явное разделение владения и доступа (например, вектор владельцев указателей плюс структура данных для указателей ссылок, не являющихся владельцами). Другой способ заключается в использовании интеллектуальных указателей (например, std: shared_ptr) плюс тесты на цикличность.
8. И что?
Статический анализ, на который я полагаюсь для получения гарантий, еще не реализован на 100% (но приближается к этому), а то, что доступно, доступно не на каждой платформе. Если бы это было так, это было бы огромным преимуществом для всех разработчиков C++. Универсальная доступность статического анализа основных рекомендаций была бы гораздо более значимой, чем какие-либо расширения для одного языка, и гораздо проще / дешевле в реализации. Кроме того, это было бы продолжением традиций C и C++ в проведении различия между тем, что является законным в стандарте, и тем, что является хорошей разработкой программного обеспечения. Компилятор не является нашим единственным инструментом и никогда им не был.
То, что проверяется статически, должно быть принципиальным и точно заданным. Основные рекомендации C++ являются значительным усилием в этом направлении. Кроме того, полнота гарантий безопасности должна быть — насколько это возможно — формально доказана.