Рассматривая графики метрик, отображающие число ожидающих обработки запросов в момент времени, дежурный инженер заметил, что с графиком что-то не так: отсутсвие какого-либо пульса, просто горизонтальная прямая. Взглянув на ось Y, инженер увидел, что график замер на значении 18446744073709552000. Такому вселенскому масштабу траффика могли бы позавидовать все крупные компании. Но, разумеется, никакого траффика не было. А была ошибка в метрике, даже целых две. Ну и чтобы все любители C++ порадовались, скажу, что этот код был написан на Rust.
Первая ошибка родилась от небрежно написанного кода подстчета реквестов в очереди. Вместо того чтоб возложить эту обязанность на пару конструктор/деструктор и гарантировать ровно один инкремент в начале и один декремент в конце, программист решил пойти старыми дедовскими путями и выполнять декременты в разных ветках разных вложенных условных операторов. В результате иногда вычетание происходило дважды, приводя к переполнению беззнакового счетчика. В процессе детального разбора выяснились и другие более серьезные проблемы, но они не имеют никакого отношения к теме этой главы.
Произошло переполнение. Хорошо. Счетчик похоже 64битный раз такое большое число. Но постойте.
Беззнаковый -1 в uint64
это 18446744073709551615. А число, которое увидел инженер немножко больше...
Код, генерирующий метрики, имел следующий вид
metrics.set(Metric::InflightRequests, counter as _);
Очевидно, в деле было замешано приведение типов. Второй аргумент метода set
ожидал тип f64
.
Любознательный читатель должен поглядеть в стандарт IEEE 754 и найти разгадку магии чисел. Для менее любознательного читателя скажу лишь, что f64(u64(-1)) == f64(u64(-1024))
counter as _
Явное преобразование к чему-то непонятному, известному из контекста, но совсем не очевидному при чтении. Полезная и сомнительная фича Rust.
Теперь мы можем вернуться к C++. В C++ приведения тривиальных типов не только происходят неявно, но еще и иногда приводят к неопределенному поведению, поэтому к вопросу нужно подойти максимально серьезно.
Итак, вы — автор библиотеки. Вы желаете сделать ее максимально надежной, как можно более защищенной от дурака и как можно более дружелюбной к пользователю, чтоб компилятор смог направить его к единственно правильному варианту использования.
Приняв во внимание фиаско с кодом на Rust, которое я вам только что описал, вы сразу же решаете прибегнуть к помощи strong typedefs (впрочем в случае Rust кода они бы тоже помогли)
// Тут вы написали очень длинный и развернутый комментарий,
// что значения имеют тип double (f64), потому что так надо.
// И пользователь должен иметь в виду сопутствующие ограничения
// И все такое прочее...
struct MetricSample{
// чтобы избежать неявного приведения вы сразу же
// добавили explicit, как советуют все best practices
explicit MetricSample(double val): value {val} {}
private:
double value;
};
class Metrics {
public:
// Отлично, теперь у пользователя нет иного варианта как выполнить
// явное преобразование к MetricSample, а там уж он почитает документацию...
void set(std::string_view metric_name, MetricSample val);
};
// Вы пишете UX тест
int main() {
uint64_t value = -1;
Metrics m;
m.set("InflightRequests", value);
m.set("InflightRequests" MetricSample{value});
// И он не компилируется, как вы и хотели https://godbolt.org/z/vK65zWfaE
}
Отлично. Дело сделано. Релизим.
Через неделю к вам приходит опытный пользователь и говорит, что отстрелил себе ногу вашей библиотекой.
Ваша защита от неявного приведения типов не содержит защиты от опытного дурака:
int main() {
uint64_t value = -1;
Metrics m;
m.set("InflightRequests", MetricSample(value));
// И оно компилируется https://godbolt.org/z/Pcer5zcG7
}
И тут вы, давно наслаждающиеся всеми прелестями современнейшего С++, совершенно безопасного в верных руках с C++ Core Guidlines, вспоминаете про эту проклятую разницу между круглыми и фигурными скобками при вызове конструкторов, хватаетесь за голову и начинаете думать, как спасти вашего пользователя от него самого.
Решение есть! Спасибо, C++20:
#include <concepts>
struct MetricSample{
// Теперь только double может быть передан.
// Никаких неявных преобразований поскольку это шаблон
explicit MetricSample(std::same_as<double> auto val): value {val} {}
private:
double value;
};
int main() {
uint64_t value = -1;
Metrics m;
m.set("InflightRequests", MetricSample(value));
m.set("InflightRequests", MetricSample{value});
m.set("InflightRequests", value);
// теперь ничего не компилируется https://godbolt.org/z/1cMn4ca6c
}
Всё? Нет, подождите. Это же C++, а у нас не у всех есть C++20! Вот версия для C++14 и C++17. Даже для C++11 можно сделать (можете взять это в качестве домашнего задания)
#include <type_traits>
struct MetricSample{
// Теперь только double может быть передан.
// Никаких неявных преобразований поскольку это шаблон
template <typename Double,
typename = std::enable_if_t<std::is_same_v<Double, double>>
>
explicit MetricSample(Double val): value {val} {}
private:
double value;
};
Надеюсь, она убедит вас и ваших пользователей переходить на C++20 и новее.
Время идет. Ваша библиотека набирает популярность. В какой-то момент к вам приходит пользователь и говорит: хотелось бы мне еще добавлять комментарий к значению метрики.
Не вопрос. Вы решаете добавить перегрузку метода set
с третьим, строковым, параметром.
class Metrics {
public:
// Чтоб сделать явной для пользователя необходимость аллоцировать память под строку
// и не делать лишних неявных копий, вы решаете использовать rvalue reference.
// ведь это отличный способ продемонстрировать что ваш интерфейс желает заполучить
// владение строкой. И пользователь будет должен выполнить явный move
void set(std::string_view metric_name, MetricSample val, std::string&& comment);
}
int main() {
Metrics m;
auto val = MetricSample(1.0);
std::string comment = "comment";
m.set("MName", val, comment); // не компилируется, как и хотели
m.set("MName", val, "comment"); // сомнительно, но для удобства Ok
m.set("MName", val, std::move(comment));
m.set("MName", val, std::string_view("comment")); // не компилируется, хорошо
auto gen_comment = []()->std::string { return "comment"; };
m.set("MName", val, gen_comment()); // отлично
}
// https://godbolt.org/z/PTz1f9rPW
Всё хорошо. Релизим. Через два дня к вам приходит пользователь и говорит, что он отстрелил себе ногу вашей библиотекой. И показывает ЭТО:
int main() {
Metrics m;
auto val = MetricSample(1.0);
m.set("Metric", val, 0);
}
// https://godbolt.org/z/acvWhc4qh
terminate called after throwing an instance of 'std::logic_error'
what(): basic_string: construction from null is not valid
Program terminated with signal: SIGSEGV
В этот момент вы проклинаете класс std::string
, неявную интерпретацию 0 как указателя, а также пользователя, который совершенно не читает не только документацию, но и вообще код что он написал.
Но вы справляетесь с желанием написать свой собственный класс строк и начинаете думать, как подстелить солому и в этом случае.
Здесь, конечно, начинаются самые разные варианты. Мы можем разрешить только rvalue string:
class Metrics {
public:
// только rvalue ссылки на string. Никакого неявного приведения типов
void set(std::string_view metric_name,
MetricSample val,
std::same_as<std::string> auto&& comment) {};
};
int main() {
Metrics m;
auto val = MetricSample(1.0);
std::string comm = "comment";
m.set("Metric", val, comm); // не компилируется
m.set("Metric", val, 0); // не компилируется
m.set("Metric", val, std::move(comm)); // компилируется, как и хотели
m.set("MName", val, std::string_view("comment")); // не компилируется, хорошо
auto gen_comment = []()->std::string { return "comment"; };
m.set("MName", val, gen_comment()); // отлично
// https://godbolt.org/z/9Td6n7cMv
}
Но вы уже живете в проклятом мире: вы разрешали использовать строковые литералы напрямую. Если их запретить, пользователь расстроится — у него код перестанет компилироваться. Так что придется добавить и их. А чтобы пользователь не совал нулевые указатели, а использовал только строковые литералы, есть замечательное решение — ссылки на массивы! Ведь строковые литералы это массивы...
class Metrics {
public:
// Только строковые литералы и явная передача владения строкой разрешены вашим интерфейсом
void set(std::string_view metric_name, MetricSample val, std::same_as<std::string> auto&& comment) {};
template <size_t N>
void set(std::string_view metric_name, MetricSample val, const char(&comment)[N]) requires (N > 0) {
this->set(metric_name, val, std::string(comment, N-1));
}
};
int main() {
Metrics m;
auto val = MetricSample(1.0);
std::string comm = "comment";
const char* null_comment = 0;
m.set("Metric", val, "comment"); // "ok"
m.set("Metric", val, null_comment); // не компилируется
m.set("Metric", val, comm); // не компилируется
m.set("Metric", val, 0); // не компилируется
m.set("Metric", val, std::move(comm)); // компилируется, как и хотели
m.set("MName", val, std::string_view("comment")); // не компилируется, хорошо
auto gen_comment = []()->std::string { return "comment"; };
m.set("MName", val, gen_comment()); // рабоатет отлично
}
// https://godbolt.org/z/zjWGWY4xh
Все отлично. Релизим!
В какой-то момент особенно ушлый пользователь сконструирует кривой массив и передаст его вместо строкового литерала... Но в этот момент единственное что вы сможете сделать, это проигнорировать этого пользователя. Поскольку в безрезультатных попытках выразить и такое ограничение в C++ (и оставить прием литералом без изменений пользовательского кода) можно и сойти с ума.
В заключение стоит добавить, что в C++23 появилось новое применение для ключевого слова auto
:
void call_it(auto&& obj) {
call_impl(auto(obj));
}
Я видел разработчиков, которым приходится много работать одновременно с Rust и с C++, и эта новая фича для них выглядит как преобразование obj
к какому-то типу, который указан как аргумент call_impl
. Прямо как as _
или вызов Into::into()
в Rust. Это могло бы быть очень логичным...
Но нет, это совершенно другая фича. Компиляторы C++ не делают таких сложных выводов типов. auto
в этой позиции нужен, чтоб создавать копии, не имея под рукой имени типа.