Strong types
Постановка проблемы
Приходя из мира динамичкески типизированных языков или из мира слабо типизированных ( типа сишечки ) складывается привычка писать сигнатуры функций / методов, наворачивая множество параметров со схожим типом надеясь, что пользователь кода бует аккуратен / дисциплинирован и вообзе как-нибудь сам разберется.
Или код узко специализированный и выполняет манипуляции над некоторым количеством разношерстных параметров, которые взаимодействуют друг с другом или влияют на работу кода
Посмотрим, каките проблемы могут возникнуть, и какие наивные решения нам могут прийти в голову по незнанке
Case #1 – много булевых флагов
Случай первый - функия хочет многа флагов, пример перед глазами, комментарии излишни
int calculateUniverse(bool with_humans, bool with_aliens, bool with_animals,
bool with_dinasours);
//....
const auto result = calculateUniverse(true, false, true, false);
или
enum class HaveHumans { No, Yes };
enum class HaveAliens { No, Yes };
enum class HaveAnimals { No, Yes };
enum class HaveDinasours { No, Yes };
int calculateUniverse(HaveHumans with_humans, HaveAliens with_aliens,
HaveAnimals with_animals, HaveDinasours with_dinasours);
//....
const auto result = calculateUniverse(HaveHumans::Yes, HaveAliens::No,
HaveAnimals::Yes, HaveDinasours::No);
Говорят, что это антипаттерн сам по себе (и это правда), но все равно есть случаи, когда такой подход может оказаться уместным.
Самый простой способ решения (запихать флаги в структуру и передавать ее) не будем рассматривать, т.к. вместо проблемного вызова функции получается проблемнпая инициалдизация структуры
Можно создать для каждого флага строгое перечисление и передавать в функцию их. Это здорово решает проблему неоднозначности, и минусов почти что нет. Но усложняется механизм проверки - больше нельзя просто ифнуть аргумент, надо сверяться с енамом
Case #2 – много числовых аргументов
Пример №2 – функция хочет много неких численных аргументов. Числа заведомо разные – разной разрядности, знаковости, что-то даже дробное!
struct Result {
int m_value{0};
Result(int value) : m_value{value}{}
}
struct Index {
uint8_t m_value{0};
Result(uint8_t value) : m_value{value}{}
}
struct Speed {
uint32_t m_speed{0};
Result(uint32_t speed) : m_speed{speed}{}
}
Что в общем не мешает как внутри функции наделать некорректной арифметики, что пользователю вызвать функцию передав в нее черт знает что.
int performCalculation(char index, int speed, double acceleration,
long long distance)
{
rerurn distance / acceleration + speed * index;
}
// ...
const auto result = performCalculation(-53, -3456.5, 34, 123142e3);
// OK, but due to implicit conversions will loose percision
Да, возможно будет ворнинг, но кто без -Werror
вообще обращает на них внимание.
Гадость происходит из-за возможности неявных преобразований числовых типов друг в друга. Понятно, какими проблемами это сулит – потеря знаков, дробной составляющей и все такое прочее. Опять же – при вызове функции совершенно неясно, ЧТО за числа туда передаются.
Тут прям хочется сделать хоть что-то для спасения души, хотя бы не дать внутри функции выполнять грязную арифметику. Наивный подход – обернуть величины в классы/структуры, определеив конструкторы и геттеры (в примере для простоты геттеров нет).
struct Acceleration {
double m_acceleration{0.0};
Acceleration(double acceleration) : m_acceleration{acceleration}{}
}
struct Coordinate {
int64_t m_coordinate{0};
Coordinate(int64_t coordinate) : m_coordinate{coordinate}{}
}
Теперь хотя бы нельзя напропалую брать и скрещивать между собой объекты этих стуктур, нужно как минимум добираться до внутренних значений, а там и до явных кастов типов недалеко.
int performCalculation(Index index, Speed speed, Acceleration acceleration,
Coordinate distance)
{
rerurn distance / acceleration + speed * index;
}
// ...
const auto result = performCalculation(-53, -3456.5, 34, 123142e3);
// NOT OK
Но само по себе такое решение не спасет от вызова функции как раньше, будут происходит неявные преобразования.
Чтобы этого не было все конструкторы надо пометить ключевым словом explicit
(на слайде это не показано)
Ну, допустим, вызовы функций кое-как победили, хотя бы очевидных ошибок сделать нельзя.
Или, как на картинке, вот создали мы две таких величины в дикой природе, и хочется нам их сравнить.
struct Acceleration {
double m_acceleration{0.0};
Acceleration(double acceleration) : m_acceleration{acceleration}{}
}
void foo() {
const Acceleration subject_1{123.1};
const Acceleration subject_2{13.4};
const auto compare = subject_1 > subject_2;
// not OK -- invalid operands to binary expression
}
А НЕЛЬЗЯ, ведь наши новые типы есть суть контейнеры, котоыре сами по себе ничего не умеют. И начинается возня с определением различных операторов (математических и не только) для того, чтобы добавить функционал лежащего внутри числа классу-контейнеру.
struct Acceleration {
double m_acceleration{0.0};
Acceleration(double acceleration) : m_acceleration{acceleration}{}
bool operator>();
bool operator<();
bool operator==();
}
Вот вроде и здорово все, но это РУЧНАЯ РАБОТА, КОТОРУЮ НУЖНО ДЕЛАТЬ КАЖДЫЙ РАЗ, можно и ошибок наделать, и просто западло!
Case #3 – неконсистентность хранения параметров
- Если не бить по рукам, одна и та же сущность (обычно числовая) может тасоваться по кодовой базе под +- похожими типами;
- В поле класса параметр лежит как
int64_t
, геттер его вернет какuint32_t
, а сеттер вообще принимаетdouble
; - А кто по рукам-то бить будет? Дизайн документ? Фигурные скобки?
struct Entity {
int32_t main_value{0};
uint8_t not_so_long_value{0};
uint64_t quite_long_value{0};
// not OK -- uniform initialization will scream on implicit casts and won't allow compiling
Entity(uint64_t first, uint64_t second, int8_t third)
: main_value{first}, not_so_long_value{second}, quite_long_value{third} {}
// but realistically a lot of people use this form, which allows you to do whatever
Entity(uint64_t first, uint64_t second, int8_t third)
: main_value(first), not_so_long_value(second), quite_long_value(third) {}
};
что же делать?
Авторы CppCoreGuidelines рекомендуют:
I.4: Make interfaces precisely and strongly typed
namespace step1 {
void printCity(const std::string& country, const std::string& city) {
std::cout << "STEP 1, Country: " << country << ", City: " << city << '\n';
}
}
// ....
// OK
step1::printCity("New york city", "USA");
const std::unordered_map<std::string, std::string> capitals {
{"Copenhagen", "Denmark"},
{"Germany", "Berlin"}
};
std::for_each(capitals.cbegin(), capitals.cend(),
[&](const auto& item){step1::printCity(item.first, item.second);});
Попробуем сделать хоть что-то, определив “новые” типы для наших сущностей с помощью using.
namespace step2 {
using City = std::string;
using Country = std::string;
void printCity(const Country& country, const City& city) {
std::cout << "STEP 1, Country: " << country << ", City: " << city << '\n';
}
}
Получилось немного лучше, пользователь кода увидит подсказку / посмотрит на сигнатуру. Что-то ёкнет, он вставит элементы в нужном порядке.
// ok
step2::printCity("New york city", "USA");
// ok, more readable, but no guarantees yet
std::unordered_map<step2::Country, step2::City> capitals;
capitals["Uganda"] = "Wakanda";
А если не обратит внимание? Поможет ли нам компилятор? Нет, ему все еще нпонятно, чем страна отличается от города, ведь using - это синтакстчечкий сахар, не дающий механизмов защиты
typedef → using → ???
Разбираем на примере (cont.) – используем библиотеку anthonywilliams/strong_typedef.
#include <strong_typedef/strong_typedef.h>
// ...
namespace step3 {
using City = jss::strong_typedef<struct CityTag, std::string>;
using Country = jss::strong_typedef<struct CountryTag, std::string,
jss::strong_typedef_properties::comparable,
jss::strong_typedef_properties::hashable>;
void printCity(const Country& country, const City& city) {
std::cout << "STEP 3, Country: " << country << ", City: " << city << '\n';
}
}
// ...
step3::Country country{"Brazil"};
step3::City city{"Brasilia"};
std::unordered_map<step3::Country, step3::City> capitals;
capitals[country] = city;
capitals[step3::Country{"Russia"}] = "Zimbabwe"; // not ok
step3::printCity(country, capitals.at(country));
Возвращаясь к примерам со скоростями – строго типизируем наши величины!
using Index = jss::strong_typedef<struct index_id, uint8_t,
jss::strong_typedef_properties::comparable>;
using Address = jss::strong_typedef<struct address_id, uint8_t,
jss::strong_typedef_properties::comparable>;
using Coordinate = jss::strong_typedef<struct coordinate_id, int64_t,
jss::strong_typedef_properties::comparable,
jss::strong_typedef_properties::addable,
jss::strong_typedef_properties::substractable>;
using Acceleration = jss::strong_typedef<struct address_id, double,
jss::strong_typedef_properties::comparable>;
auto performCalculation(Index index, Address addres, Speed speed,
Acceleration acceleration, Coordinate distance) {
Coordinate temp_target{4000};
if (temp_target > distance) {
temp_target += distance;
}
if (Index{23} == index) {
std::cout << "correct index\n";
}
if (index < address) {
// blegh
}
// not ok
const auto value{distance / acceleration + speed * index};
return acceleration.underlying_value();
}
// ...
// not ok
const auto result = performCalculation(-53, -234.1, 13, 23e2);
Какой предлагается генерируемый функционал?
comparable
,ordered
– поддержка операторов ==, !=, <, >, <=, >=addable
,substractable
– поддержка операторов + и - как в связке с объектом этого типа, так и с объектом underlying типаhashable
– поддержка хэширования в виде ключа вstd::hash
streamable
– поддержкаoperator<<
дляstd::ostream
- И еще много всего!
Достоинства ✅ и подводные камни 🗿
Плюсы
- API становится человекочитаемым – нет вопросов, какое именно значение хочет получить метод/функция;
- API становится компиляторочитаемым – он отличает
string
отstring
, не закрывает глаза на преобразованияdouble
вint
; - Строгие типы можно использовать для хранения значений в полях классов;
- Гарантия представления сущности одним и тем же типом по всей кодовой базе;
- Определение новых типов не требует больших усилий;
- Для реализации дополнительных плюшек (поддержка операторов сравнение, стриминга и т.д.) достаточно добавить флаг в декларацию нового типа – не надо писать перегрузки самостоятельно;
- Обещается zero overhead использование.
Минусы:
- Полагаться на чужие библиотеки в своем проекте (NIH синдром);
- Может показаться золотым молотком и применяться там, где нет необходимости (например, можно обойтись enum class’ом);
- В ситуации, когда понадобится самостоятельно добавить функциональности – нужна жоская шаблонная магия.
Использованные источники
- B. Filipek, “Ways to Refactor Toggle/Boolean Parameters in C++,” C++ Stories. https://www.cppstories.com/2017/03/on-toggle-parameters/.
- B. Filipek, “Strong Types in C++: A Concrete Example,” C++ Stories. https://www.cppstories.com/2021/strong-types-pesel/.
- Using strong_typedef with Qt for Improved Safety. https://www.youtube.com/watch?v=zrz_AXpgkrE
- A. Williams, “
jss::strong_typedef<Tag, ValueType, Properties...>
.” https://github.com/anthonywilliams/strong_typedef - “isocpp/CppCoreGuidelines.” Standard C++ Foundation. https://github.com/isocpp/CppCoreGuidelines