Пишемо 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 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів