четверг, 8 апреля 2021 г.

[prog.c++.bicycle] Отдельный тип для строковых литералов своими руками

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

class long_living_object {
  const char * value_;
public:
  long_living_object(const char * v) : value_{v} {}
  ...
};

long_living_object make_object_ok() {
  return { "Hello, World!" };
}

то у нас, в принципе, все нормально. В возвращаемом функцией make_object_ok объекте будет находиться валидный указатель.

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

long_living_object make_object_bug() {
  char greeting[] = "Hello, World!";
  return { greeting };
}

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

Все это особенно обидно в ситуациях, когда мы точно знаем, что long_living_object должен содержать указатели только на строковые литералы. Например, когда long_living_object -- это что-то вроде:

struct negative_response {
  std::uint16_t status_code_;
  const char * reason_phrase_;
};

и negative_response::reason_phrase_ содержит указатели на строковые литералы с заранее заготовленными описаниями стандартных статус-кодов HTTP.

Мы можем использовать тип negative_response в коде, сопровождая этот код предупреждениями, что reason_phrase_ должен быть указателем на строковый литерал. И какое-то (возможно даже долгое) время это будет работать. Пока кто-нибудь, где-нибудь, когда-нибудь не ошибется. И компилятор нам этого никак не поможет предотвратить такую ошибку.

И в этом плане я очень завидую Rust-у, где есть специальный lifetime обозначаемый как 'static. Если вы в Rust-е получаете ссылку с таким lifetime, то можете быть уверены, что эта ссылка не "повиснет". Но, самое важное, Rust вам не позволит подсунуть ссылку на временный объект туда, где ожидается 'static.

И до недавнего времени я думал, что в C++ с указателями на строковые литералы нам остается только облизываться на то, что есть в Rust-е.

Конечно же, были способы представления строк, которыми можно оперировать в compile-time. Вот здесь можно найти вариант, как раз подходящий для решения обсуждаемой проблемы.

Но эти варианты мне не нравились тем, что у каждой такой строки получался свой собственный тип. И нужно было поломать голову над тем, как заставить работать типы, вроде показанных выше long_living_object/negative_response, с compile-time строками на базе шаблонов, параметризованных последовательностями символов.

Давеча в голову пришла мысль, что можно использовать user-defined literals из C++11 и более свежих C++ных стандартов. Поскольку определенный пользователем operator для преобразования строкового литерала в пользовательский тип применяется именно к строковому литералу (а литералы живут в статической памяти), то указателю на этот литерал мы можем доверять.

Значит, остается сделать такой тип-обертку вокруг указателя на char, экземпляры которого мог бы создавать только operator для user-defined literals. Что делается элементарно посредством приватного конструктора и объявления friend-а для этого типа.

В результате получается вот такое тривиальное решение (я не заморачивался здесь на расстановку constexpr, хотя практически все в коде может и должно быть отмечено как constexpr):

namespace utils
{

class string_literal;

namespace literals
{

[[nodiscard]]
string_literal operator""_str(const char * value, std::size_tnoexcept;

/* namespace literals */

class string_literal 
{
   const char * m_value;

   string_literal(const char * value) noexcept : m_value{value} {}

public:
   [[nodiscard]]
   const char * raw_value() const noexcept { return m_value; }

   friend
   string_literal literals::operator""_str(const char * value, std::size_tnoexcept;
};

namespace literals {

[[nodiscard]]
inline string_literal operator""_str(const char * value, std::size_tnoexcept
{
   return { value };
}

/* namespace literals */

/* namespace utils */

Используется это следующим образом:

int main()
{
   using namespace utils::literals;

   const auto v1 = "first"_str;
   auto v2 = "second"_str;
   auto v3 = v1;

   std::cout << v1.raw_value() << ", " << v2.raw_value() << ", "
         << v3.raw_value() << std::endl;
}

Поиграться с реализацией можно здесь: https://wandbox.org/permlink/QAhaCIzhF8xj3mVT.

Имея такой класс string_literal мы можем написать:

struct negative_response {
  std::uint16_t status_code_;
  utils::string_literal reason_phrase_;
};

И при этом сам компилятор будет бить нам по рукам за попытки засунуть в negative_response::reason_phrase_ указатель на временный массив char-ов. Но, при этом, все строковые литералы будут представляться одним и тем же типом, вне зависимости от содержимого литерала, что дает нам возможность делать так:

using namespace utils::literals;
static const auto not_found = "Not Found"_str;
static const auto bad_request = "Bad Request"_str;
static const auto access_denied = "Access Denied"_str;
...
std::variant<ok, negative_response> check_access(const request_data & rd) {
  ...
  if(!has_permission(rd))
    return negative_response{403, access_denied};
  ...
}

Нужно пояснить почему в приведенной реализации string_literal есть вспомогательное пространство имен literals.

Дело в том, что, насколько я помню, нельзя вызывать user-defined literals operator с указанием пространства имен из которого этот оператор нужно вызывать. Т.е. нельзя написать так:

const auto v1 = "first"utils::literals::_str;

Значит, перед тем, как использовать operator""_str нужно сделать using namespace. Что не очень хорошо, если нам нужно использовать operator""_str где-то в заголовочном файле.

Поэтому, имхо, лучше разместить operator""_str в отдельном крошечном пространстве имен, применение using namespace для которого не будет проблемой, даже если это выполняется в заголовочных файлах.


Решил поделиться этой идеей с читателями блога.

Мне кажется, что подобный string_literal вполне легален и никаких проблем при его использовании быть не должно.

Но, т.к. я не очень хорошо понимаю, что и в какие сегменты памяти распределяется компилятором/линкером, то могу и ошибаться. Если кто-то видит в предложенном решении фатальный недостаток, то не сочтите за труд, дайте, пожалуйста, знать.

Да, на оригинальность идеи ни в коем случае не претендую. Не помню, чтобы мне доводилось читать о таком подходе именно в этом виде. Но, вполне возможно, что когда-то что-то на глаза попадалось, а вспомнилось вот только на днях.


Естественно, ни о какой безопасности использования string_literal не может быть и речи, если мы начинаем вручную загружать и выгружать динамические библиотеки (dll/so). Если литерал определен внутри вручную загруженной DLL, а потом мы эту DLL вручную выгрузили, то это неизбежно приведет к появлению повисших указателей.

5 комментариев:

jazzer комментирует...

Да, делал такой, назвал его _lit :)
Главное использование - для передачи строк из рабочего потока в логирующий (чтоб не тормозить рабочий).

Pavel комментирует...

Я начал использовать `static const std::string_view` для строковых констант, но не додумался до собственного оператора. Спасибо за идею, попробую использовать `struct string_literal : string_view {}` с закрытом конструктором.

eao197 комментирует...

@Pavel

Я тоже сперва использовал static const std::string_view, но здесь такая же засада, как и с просто const char*. Поэтому все равно продолжал думать в сторону аналога 'static из Rust-а.

> попробую использовать `struct string_literal : string_view {}` с закрытом конструктором.

Интересная мысль. Я до такого радикального решения пока не дошел, ограничился вот этим: https://github.com/Stiffstream/arataga/blob/4d93aadf86a5914b021d622b693069437dceba54/arataga/utils/string_literal.hpp#L106-L133

Pavel комментирует...

@eao197

У вас в string_literal есть оператор конверсии в std::string_view, так что при желании можно использовать все его функции-члены. Мой вариант с публичным наследованием - это больше от лени :)

eao197 комментирует...

@Pavel

Просто в моих сценариях с содержимым литерала вообще ничего не нужно было делать кроме записи в лог и/или сокет. А вот если бы нужно было литералы сравнивать, вычленять фрагменты, искать подстроки и т.д., то тут нужно было бы думать. И тогда вариант с наследованием от string_view следовало бы рассмотреть самым тщательным образом.

Хотя, наверное, в этом случае какой-нибудь substr должен был бы возвращать string_literal, а не string_view.