std::string_view конструируется из временных экземпляров строк
Отличная идея или новый подводный камень?
Вы читаете вольный перевод статьи “std::string_view accepting temporaries: good idea or horrible pitfall?”. Перевод написан от первого лица.
C++17 принёс нам std::string_view
. Это крайне полезный класс: с ним вы можете написать функцию, принимающую параметр-строку, но требующую не владения этой строкой, а всего лишь права просмотра.
Такая функция будет работать как с аргументами const char*
, так и с std::string
без лишних усилий со стороны программиста и без выделения памяти со стороны программы. Более того, класс string_view
лучше отражает намерение: функция получает обзор строки. Она ничем не владеет, она просто смотрит на неё.
Как человек, поддерживающий идею стремления к корректности типов, я счастлив видеть std::string_view
. Однако, в нём заложено одно достаточно спорное решение: std::string_view
по умолчанию принимает временные строки. И если такая ссылка продолжает жить после освобождения временной строки, то может возникнуть проблема — она будет ссылаться на удалённые данные.
Мы рассмотрим причины, повлиявшие на это решение, и узнаем тонкости использования std::string_view
.
Проблемы из-за конструирования из временной строки
Предположим, вы пишете класс, хранящий экземпляр std::string
и имеющий метод для доступа к ней:
Метод возвращает строку по константной ссылке. Тем самым отражается факт, что вы используете тип std::string
, а пользовательский код зависит от данного типа. Если позднее вы решите перейти на другой строковый тип или даже на std::string
с другим аллокатором, вам придётся изменить возвращаемый тип, т.е. изменить API.
Вы можете использовать std::string_view
для решения такой проблемы:
Теперь внутри класса foo или в пользовательском коде вы можете использовать любую реализацию строк, лишь бы она хранила внутри непрерывный массив char
. В этом прелесть корректных абстракций в целом и std::string_view
в частности.
Однако, завтра требования изменятся и вы начнёте хранить в поле класса больше информации. Не будет времени для полноценного рефакторинга, и вы решите добавить новую информацию в виде префикса. Поздней ночью в конце затянувшегося рабочего дня вы измените метод, чтобы для совместимости он возвращал подстроку вместо полной строки:
Как работает этот код? Как он должен работать по замыслу автора? На второй вопрос легче ответить: вы просто хотите дать право обзора подстроки, какие могут быть проблемы?
Проблема в том, что std::string::substr()
возвращает временный объект типа std::string
. А метод возвращает обзор (т.е. view) временной строки, которая уже исчезнет к тому моменту, когда обзором можно будет воспользоваться.
Правильным решением будет явная конвертация к std::string_view
до вызова substr
:
Метод substr()
для std::string_view
более корректен: он возвращает обзор подстроки без создания временной копии. Едва уловимое изменение исправляет код.
Настоящая проблема в том, что в идеале метод std::string::substr()
должен возвращать std::string_view
. И это лишь один из аспектов более общей проблемы висячих ссылок, не решённой в языке C++.
В нашем случае был очень простой способ предотвратить висячую ссылку: прямо в стандарте языка могли бы запретить принимать временные строки в конструкторе std::string_view
, что привело бы к ошибке компиляции некорректного кода. Такое решение не избавило бы от всех висячих ссылок, но оно позволяло бы избегать ряда нелепых ошибок. Обнаруживать хотя бы некоторые ошибок лучше, чем не обнаруживать никакие.
Ради справедливости заметим, что анализатор clang-tidy имеет проверку, способную обнаружить данную проблему. Но clang-tidy — это не ваш повседневный компилятор.
Так почему же std::string_view
принимает временные строки? Комитет по стандартизации собран из умных людей, которые прекрасно видели, что string_view
принимает временные строки, и знали как это предотвратить. Почему они этого не сделали? Потому что такой запрет нарушил бы основной сценарий использования std::string_view
.
Плюсы конструирования из временных строк
Класс std::string_view
идеален в роли невладеющего параметра-строки:
Любая функция, которая раньше работала с const char*
либо с const std::string&
, в C++17 может просто использовать std::string_view
— до тех пор, пока ей не требуется владение данными строки.
Поскольку в C++ временные объекты, созданные в выражении, не удаляются до завершения всей инструкции, вы не получите висячих ссылок при передаче временной строки как аргумента при вызове:
В данном примере во всех вызовах функций передаются временные строки, которые будут удалены лишь в конце выполнения соответствующей инструкции, т.е. уже после вызова функции. И если функции не сохраняют обзор строки куда-либо ещё, то нет никаких висячих ссылок.
Получение в виде параметра временных строк не только работает, но и является хорошей практикой. Если бы такое не допускалось, то в примере ниже вы не смогли бы использовать вариант №1 и пришли бы к более многословному варианту №2:
Как правильно использовать обзоры строк
Совершенно безопасно использовать std::string_view
как параметр функции, если функции нужно не владение, а обзор строки, и не требуется сохранять обзор для последующего использования в другом месте.
Будьте осторожны, используя std::string_view
в роли возвращаемого значения. Убедитесь, что функция не пытается вернуть временную строку. Будьте аккуратны при вызове std::string::substr()
.
Будьте осторожны, пытаясь сохранить std::string_view
где-либо ещё, например в поле класса. Вам будут нужны гарантии, что обозреваемая строка существует дольше, чем обзор строки.
Не используйте std::string_view
как тип локальной переменной, лучше использовать auto&&
. Дело в том, что в C++ при создании первой ссылки на временный объект его время жизни увеличивается вплоть до уничтожения первой ссылки. К сожалению, std::string_view
не обладает таким же эффектом.
Хотя такое руководство выглядит исчерпывающе, мне оно не очень по душе. Здесь слишком много нюансов. Язык C++ уже достаточно сложен, и не стоит добавлять в него больше сложности. Чтобы снизить сложность языка в своём проекте, используйте грамотно систему типов.
Классы function_view и function_ref
Не так давно Vittorio Romeo опубликовал пост с примером реализации класса function_view. Этот класс служит эквивалентом std::string_view
, но применим в паре с std::function
. Он точно так же конструируется из временных объектов и нацелен на устранение известной идиомы передачи функции как параметра с нулевыми расходами:
Чтобы упростить подобный код, вместо создания шаблонного параметра используйте function_view
с заданной сигнатурой.
Параллельно с созданием данным автором класса function_view
я работал над классом object_ref
в своей собственной библиотеке type_safe. Класс object_ref
— эквивалент ненулевого указателя. Он предназначен для хранения долговечной ссылки, например в поле класса, и не должен принимать временных значений (rvalues), потому что после присваивания временное значение было бы уничтожено.
Прим. переводчика — класс
object_ref
подобен классуgsl::non_null
из библиотеки GSL, хотя автор видит некоторые различия. Отstd::reference_wrapper
его отличает семантика: это указатель, никогда не равный нулю.
После чтения поста Vittorio я решил написать подобный класс, который не конструируется из временных значений. Я назвал его function_ref
на манер уже написанного object_ref
. Я даже написал пост об этом, потому что реализовать такую функцию оказалось сложнее, чем вы могли бы подумать.
После этого поста на reddit разгорелась дискуссия. Читатели совершенно правильно указали мне, что запрет конструирования из временных объектов делает класс неудобным в роли параметра функции.
И тогда я понял: function_view
и function_ref
— это два противоположных типа! Если function_view
спроектирован для параметров функций, то function_ref
предназначен для долговременного хранения невладеющей ссылки на функцию.
Типы-обзоры и типы-ссылки
Поскольку невладеющая ссылка в качестве параметра имеет семантику в сравнении с любой другой невладеющей ссылкой, имеет смысл реализовать для них два разных типа.
Один из типов — обзор (view) — проектируется для параметров. Он может конструироваться из временных значений. Обычный const T&
также является таким обзором данных.
Другой тип — ссылка (reference) — спроектирован для остальных применений. Он не должен конструироваться из временных объектов. Более того, конструктор типа должен иметь атрибут explicit
, чтобы вам пришлось явно выражать намерение создать ссылку в пользовательском коде:
В данном примере при чтении кода с вызовами очевидно, как именно каждая из функций распоряжается строкой и где нужно следить за временем жизни.
Обычный указатель похож на ссылку, поскольку он имеет явный синтаксис создания: &str
. Однако, он по сути является опциональной ссылкой, потому что может быть нулевым.
Прим. переводчика — указатели имеют тяжёлое наследие: вслед за языком C многие С++ программисты используют указатели без проверки на
nullptr
(даже без простейшего assert). Поэтому стоит быть аккуратным, используя указатель как опциональную ссылку — другие программисты могут вас неправильно понять.
Я называл типы *view и *ref, но ключевая идея не в названиях. Ключевая идея выражается в следующем:
- если вам нужна невладеющая ссылка, воспользуйтесь обзорным либо ссылочным типом
- используйте обзорные типы только как параметры функций, поскольку они имеют короткое время жизни
- используйте ссылочные типы, если вам важно избегать копирования данных при возврате значения из метода или при хранении ссылки в поле класса; следите аккуратно за временем жизни объекта, на который вы ссылаетесь
Прим. переводчика — и всё-таки используйте не ссылки, а владеющие данные для хранения данных и возврата значений в повседневном коде. Излишнее копирование гораздо лучше, чем излишняя висячая ссылка, полученная по неосторожности. Преждевременная оптимизация — корень всех зол.
Заключительные слова
Стандартная библиотека не предлагает типа std::string_ref
с соответствующей семантикой, и теперь уже поздно его добавлять. Поэтому вам придётся просто следовать моему совету и быть осторожным с временными значениями, и компилятор вам в этом не поможет.
Но не забывайте, что вы можете создавать обзоры или ссылки на множество иных типов, таких как массивы, функции и т.д.
При реализации собственного типа-обзора подумайте, не пора ли создать связанный тип-ссылку? Вы легко сможете выделить между ними базовый класс, потому что различие будет только в конструкторе.
Во многих случаях вам не нужны специальные типы-обзоры. Тип const T&
великолепно подходит, если вам нужен обзор всего лишь для одного типа T
. И вы можете использовать ts::object_ref
, gsl::non_null
или T*
в роли ссылки на обычный объект.
Последний совет касается параметров обычных функций, которые просто в неё передаются, например, входных и выходных параметров. Для входных параметров либо просто передавайте их по значению, либо перегружайте функцию для const T&
и T&&
. Что насчёт выходных параметров? Эту тему я описал в другом посте.
Прим. переводчика — C++ Core Guidelines предлагают для выходных параметров использовать просто инструкцию return. Если вам нужно вернуть несколько значений, используйте либо неименованный кортеж (
std::pair
илиstd::tuple
), либо именованный кортеж (то есть обычный типstruct
).Для упрощения работы с возвратом нескольких значений стандарт C++17 предлагает так называемый “structural binding”, что также известно как “деконструирующее объявление” или “декомпозирующее объявление”. Пример приведён ниже: