Пишемо Builder із валідацією обов’язкового виклику методів
Вітаю, шановне товариство. Мене звати Євген, я Senior C++ Engineer в компанії Intellias. У вільний від роботи час я люблю експериментувати й займатися пошуком рішень, що підвищують надійність коду.
Доволі часто в мене виникало питання стосовно того, як можна покращити усім відомий патерн проєктування Builder. На відміну від звичайного конструктора, Builder дозволяє створювати обʼєкт покроково. Це накладає свої обмеження — як зрозуміти, що необхідні методи Builder’а були викликані та наявні всі значення для конструювання?
Найбільша технічна конфа ТУТ!🤌
Хоч на перший погляд питання звучить тривіально, подумайте про випадки, коли встановлення значень у Builder розпорошені по довгому методу з купою умовних переходів або взагалі викликаються з різних методів.
Гарантія, що всі необхідні методи викликані, у цьому випадку повністю лягає на плечі розробника. Звісно, можна себе убезпечити, написавши тести, що автоматизують перевірку всіх (чи майже всіх) випадків використання Builder’а. І так треба робити, але чи є ще варіанти?
У цій статті я хочу поділитися ідеями використання цього патерну, котрі забезпечують побудову об’єкта з автоматизацією перевірки наявності всіх необхідних для конструювання значень.
Перш ніж почати
Пропоную одразу працювати з чимось конкретним і ввести доволі простий клас Car
, якого доля та ми обрали терпіти всі наші експерименти. Клас матиме наступний вид:
class Car { public: const std::string &GetBrandName() const { return brand_name_; } const std::string &GetModelName() const { return model_name_; } unsigned GetProductionYear() const { return production_year_; } unsigned GetMileage() const { return mileage_; } unsigned GetMaxFuelLevel() const { return max_fuel_level_; } unsigned GetCurrentFuelLevel() const { return current_fuel_level_; }; void SetCurrentFuelLevel(unsigned new_current_fuel_level) { assert(new_current_fuel_level <= max_fuel_level_ && "New current fuel level cannot exceed maximum fuel level."); current_fuel_level_ = new_current_fuel_level; } private: std::string brand_name_; std::string model_name_; unsigned production_year_{0u}; unsigned max_fuel_level_{0u}; unsigned mileage_{0u}; unsigned current_fuel_level_{0u}; };
Я навмисне спрощую деталі, аби клас Car
мав доволі інтуїтивну сигнатуру і не обтяжував нас незначними нюансами. Отже, тепер можемо переходити до нашої проблеми.
Звичайний Builder
Звичайний Builder
поступово накопичує в собі необхідні дані: чи то в окремих змінних, чи одразу в об’єкті (завдяки C++ ідіомі friend
).
Додамо клас CarBuilder
:
class CarBuilder { public: CarBuilder &WithBrandName(const std::string &brand_name) { car_.brand_name_ = brand_name; return *this; } CarBuilder &WithModelName(const std::string &model_name) { car_.model_name_ = model_name; return *this; } CarBuilder &WithProductionYear(unsigned production_year) { car_.production_year_ = production_year; return *this; } CarBuilder &WithMileage(unsigned mileage) { car_.mileage_ = mileage; return *this; } CarBuilder &WithMaxFuelLevel(unsigned max_fuel_level) { assert(max_fuel_level != 0 && "max_fuel_level_ must be bigger that zero"); car_.max_fuel_level_ = max_fuel_level; return *this; } CarBuilder &WithCurrentFuelLevel(unsigned current_fuel_level_) { car_.current_fuel_level_ = current_fuel_level_; return *this; } Car Build() { return car_; } private: Car car_; }; І оновимо клас <code>Car</code>: class CarBuilder; class Car { public: // ... private: friend class CarBuilder; Car() = default; std::string brand_name_; // ... };
Але така реалізація не гарантує наявність усіх необхідних для створення об’єкта значень. Звісно, можна додати спеціальну валідацію наявності параметрів для побудови всередині методу Build()
, проте маємо ризик потонути у великій кількості умовних перевірок на наявність, чи відсутність тих, чи інших даних, і мусимо пильно слідкувати за цими перевірками під час оновлення класу.
Ось доволі примітивна перевірка на наявність необхідних даних для коректної побудови об’єкта:
Car CarBuilder::Build() { assert(!car_.brand_name_.empty() && "car_.brand_name_ is not set"); assert(!car_.model_name_.empty() && "car_.model_name_ is not set"); assert(car_.production_year_ != 0 && "car_.production_year_ is not set"); assert(car_.max_fuel_level_ != 0 && "car_.max_fuel_level_ is not set"); return car_; }
І, певна річ, таких перевірок може бути набагато більше. До того ж доволі складно зрозуміти, чи було додане значення параметра, який сам по собі має змістовне значення за замовчуванням:
class Car { // ... private: bool was_ever_used_{false}; };
class CarBuilder { public: // ... CarBuilder& WithWasEverUsed(bool was_ever_used){ car_.was_ever_used_ = was_ever_used; return *this; } };
У цьому випадку, в разі, коли ми конструюємо об’єкти, що відображають вживані автомобілі, як зрозуміти, чи був викликаний метод WithWasEverUsed(bool)
, чи збереглося значення за замовчуванням {false}
з декларації класу? Для цього доведеться в CarBuilder
додавати проміжну змінну типу bool
, яка відображатиме розуміння, чи встановлювалось необхідне значення:
class CarBuilder { public: // ... CarBuilder& WithWasEverUsed(bool was_ever_used){ car_.was_ever_used_ = was_ever_used; is_set_was_ever_used_ = true; return *this; } private: // ... bool is_set_was_ever_used_{false}; };
Тоді можемо зрозуміти консистентність накопичених значень у Builder’і:
Car CarBuilder::Build() { // ... assert(is_set_was_ever_used_ != false && "car_.was_ever_used_ is not set"); return car_; }
Виглядає перенавантажено. Спробуємо інший підхід.
Покращений Builder
Цей підхід являє собою комбінацію звичайного Builder’а з enum
та bitset
. Така зв’язка допомагає автоматизувати перевірку на встановлення необхідного значення.
Додаймо enum
з назвою MandatoryValues
, в якому вкажемо обов’язкові значення, відсутність яких не дозволяє правильно сконструювати об’єкт:
class CarBuilder { // ... private: enum MandatoryValues { kBrandName = 0, kModelName, kProductionYear, kMileage, kMaxFuelLevel, Count }; std::bitset<MandatoryValues::Count> mandatory_values_; // ... };
Можна побачити, що останнім елементом енума MandatoryValues
є Count
. Він відповідає за кількість необхідних значень і використовується при ініціалізації бітсету mandatory_values_
. Під час перевірки наявності всіх параметрів Count
не враховується, головне завжди тримати його останнім.
Подивимось, як нові зміни впливають на CarBuilder
. До кожного методу With...()
, який встановлює обов’язкове значення, треба додати встановлення в бітсет відповідного значення енума, а в методі Build()
викликати вбудовану перевірку встановлених значень: std::bitset<>::all()
.
class CarBuilder { public: CarBuilder &WithBrandName(const std::string &brand_name) { car_.brand_name_ = brand_name; mandatory_values_.set(MandatoryValues::kBrandName); return *this; } CarBuilder &WithModelName(const std::string &model_name) { car_.model_name_ = model_name; mandatory_values_.set(MandatoryValues::kModelName); return *this; } CarBuilder &WithProductionYear(unsigned production_year) { car_.production_year_ = production_year; mandatory_values_.set(MandatoryValues::kProductionYear); return *this; } CarBuilder &WithMileage(unsigned mileage) { car_.mileage_ = mileage; mandatory_values_.set(MandatoryValues::kMileage); return *this; } CarBuilder &WithMaxFuelLevel(unsigned max_fuel_level) { assert(max_fuel_level != 0 && "max_fuel_level_ must be bigger that zero"); car_.max_fuel_level_ = max_fuel_level; mandatory_values_.set(MandatoryValues::kMaxFuelLevel); return *this; } CarBuilder &WithCurrentFuelLevel(unsigned current_fuel_level_) { car_.current_fuel_level_ = current_fuel_level_; return *this; } Car Build() { assert(mandatory_values_.all() && "Not all mandatory values were set for car creation."); mandatory_values_.reset(); return car_; } private: enum MandatoryValues { kBrandName = 0, kModelName, kProductionYear, kMileage, kMaxFuelLevel, Count }; std::bitset<MandatoryValues::Count> mandatory_values_; Car car_; };
Тепер ми можемо бути певні, що перед викликом методу Build()
всі значення встановлені. У разі, якщо це не так — спрацює assert
.
Додамо додатково вивід усіх обов’язкових значень і замінимо assert
на більш стандартний підхід з std::exception
:
Car Build() { if (!mandatory_values_.all()) { std::string mandatory_values_as_str = mandatory_values_.to_string(); std::reverse(mandatory_values_as_str.begin(), mandatory_values_as_str.end()); mandatory_values_.reset(); throw std::runtime_error( "Not all mandatory values were set for car creation. Mandatory values are: " + mandatory_values_as_str); } mandatory_values_.reset(); return car_; }
Висновок
Існує багато способів перевірки наявності всіх необхідних значень перед створенням об’єкта, а правильність його створення є ключовим аспектом у парадигмі ООП.
Автоматизація перевірки значень у Builder
дозволяє перекласти відповідальність за перевірку правильності параметрів із кінцевого користувача на саму програмну логіку. Все що залишається — вчасно оновлювати нутрощі Builder’а відповідно до нових змін об’єкта, який будуватиметься.
За цим посиланням знаходиться репозиторій з робочим прикладом, розглянутим у цій статті.
Дякую за ваш час та увагу.
30 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів