Бугаенко Егор. Элегантные объекты. Java Edition 2018.pdf
Егор Бугаенко. "Elegant Objects" (Элегантные объекты).
Эта книга представляет собой сборник практических рекомендаций (двадцать три совета сгруппированы в четыре главы), которые, как мне кажется, могут изменить ситуацию и остановить деградацию ООП.
Основная идея в книге - писать сопровождаемый код. Не смешивать процедурный код с ООП
Все существующие ООП-языки предлагают рассматривать объекты как структуры данных с прикрепленными процедурами, что в корне неверно. Появляются новые языки, но они делают так же или даже хуже. Объектно-ориентированных программистов заставляют думать так, как процедурные программисты думали 40 лет назад. То есть думать не как объекты,а как компьютеры. Мне кажется, что ООП было разработано для решения проблем процедурного программирования, особенно на языках вроде С или COBOL. Процедурный стиль написания кода очень прост для понимания теми, кто знает, что процессор последовательно обрабатывает инструкции, манипулирующие данными в памяти. Фрагмент кода на С, также известный как функция, - это множество операторов, которые должны выполняться в хронологическом порядке. Но при этом существует проблема с сопровождением. Автор кода более или менее понимает, как тот работает, пока пишет его. Но если заглянуть в него позже, то будет довольнотрудно выяснить, что имел в виду его создатель. Иными словами, код написан для компьютеров, а не для людей. Лучший примертакого императивно-процедурного языка - ассемблер. Он ближе всего к процессору и очень далек от языка, на котором люди общаются в жизни. В ассемблере нет клиентов, файлов, прямо угольников и цен. Только регистры, байты, биты и указатели - то,что процессор понимает лучше всего.
К счастью, некоторое время назад ситуация переменилась и проблема сопровождения стала более важна, чем скорость исполнения или расходование памяти. Появились более высокоуровневые парадигмы программирования, такие как: функциональная, логическая и объектно-ориентированная. Они перенесли фокус внимания с машин на людей. Они позволили нам говорить на своем языке. Они помогли сделать код более читаемым и, как следствие, более простым для поддержки. Так было задумано.
И пускай у нас есть классы и объекты - у нас все еще остались операторы, инструкции и их последовательное исполнение. Мы больше не работаем напрямую с указателями, памятью и регистрами процессора, но основной принцип остается неизменным - мы даем инструкции процессору и манипулируем данными в памяти.
Но проблема сейчас и с программным обеспечением, написанным нa java/Ruby/Python, - его невозможно поддерживать, поскольку оно никогда не было объектно-ориентированным.
1
Класс должен быть назван на основе того, чем он является, а не того, что он делает. То, что я делаю, и то, кто я есть, - две разные вещи. CashFormatter необходимо переименовать в cash, или USDCash, или CashinUSD и т. п. Иными словами, объекты должны характеризоваться своими способностями. То, что я есть, выражается в том, что я могу, а не в моих параметрах вроде роста, веса или цвета кожи. Имя класса, которое заканчивается на -er, говорит нам о том, что это создание является не объектом, а лишь набором процедур, которые могут манипулировать некоторыми данными. Это процедурный стиль мышления, унаследованный многими объектно-ориентированными разработчиками из С, COBOL, BASIC и других языков. И все-таки как правильно называть классы? Все просто: посмотрите, что инкапсулируют объекты этого класса, и придумайте для этого название.
2
Чем больше в вашем классе конструкторов, тем лучше, тем удобнее классы для меня - их пользователя. Я хочу иметь возможность создать экземпляр класса Cash многими способами. Чем больше конструкторов, тем большую гибкость применения ваших классов вы обеспечиваете мне, своему клиенту.
Перегрузка методов - фундамен тальная и очень ;важная часть ООП. Она существенно улучшает читаемость кода, семантически приближая его к языку задачи.
Основная цель - сопровождаемость. Этот принцип позволит вам снизить сложность кода и избежать дублирования.
В Rust есть несколько способов создать объект с разными типами аргументов для его создания. Вот основные подходы:
1. Конструкторы с разными именами
#![allow(unused)] fn main() { struct Person { name: String, age: u32, } impl Person { // Конструктор по умолчанию fn new(name: String, age: u32) -> Self { Self { name, age } } // Конструктор только с именем (возраст по умолчанию) fn with_name(name: String) -> Self { Self { name, age: 0 } } // Конструктор только с возрастом (имя по умолчанию) fn with_age(age: u32) -> Self { Self { name: "Unknown".to_string(), age } } // Конструктор из кортежа fn from_tuple((name, age): (String, u32)) -> Self { Self { name, age } } } // Использование let person1 = Person::new("Alice".to_string(), 30); let person2 = Person::with_name("Bob".to_string()); let person3 = Person::with_age(25); let person4 = Person::from_tuple(("Charlie".to_string(), 35)); }
2. Использование паттерна Builder
#![allow(unused)] fn main() { #[derive(Debug)] struct Car { make: String, model: String, year: u32, color: Option<String>, price: Option<f64>, } struct CarBuilder { make: String, model: String, year: u32, color: Option<String>, price: Option<f64>, } impl CarBuilder { fn new(make: String, model: String, year: u32) -> Self { Self { make, model, year, color: None, price: None, } } fn color(mut self, color: String) -> Self { self.color = Some(color); self } fn price(mut self, price: f64) -> Self { self.price = Some(price); self } fn build(self) -> Car { Car { make: self.make, model: self.model, year: self.year, color: self.color, price: self.price, } } } // Использование let car = CarBuilder::new("Toyota".to_string(), "Camry".to_string(), 2023) .color("Red".to_string()) .price(25000.0) .build(); }
3. Использование типажа From/TryFrom
#![allow(unused)] fn main() { struct Point { x: f64, y: f64, } impl From<(f64, f64)> for Point { fn from((x, y): (f64, f64)) -> Self { Self { x, y } } } impl From<[f64; 2]> for Point { fn from([x, y]: [f64; 2]) -> Self { Self { x, y } } } impl From<&str> for Point { fn from(s: &str) -> Self { let parts: Vec<&str> = s.split(',').collect(); let x = parts[0].parse().unwrap_or(0.0); let y = parts[1].parse().unwrap_or(0.0); Self { x, y } } } // Использование let point1 = Point::from((10.0, 20.0)); let point2 = Point::from([15.0, 25.0]); let point3 = Point::from("5.0,12.5"); }
4. Использование enum для разных вариантов
#![allow(unused)] fn main() { enum PersonData { Full { name: String, age: u32 }, NameOnly(String), AgeOnly(u32), } struct Person { name: String, age: u32, } impl From<PersonData> for Person { fn from(data: PersonData) -> Self { match data { PersonData::Full { name, age } => Self { name, age }, PersonData::NameOnly(name) => Self { name, age: 0 }, PersonData::AgeOnly(age) => Self { name: "Unknown".to_string(), age }, } } } // Использование let person1 = Person::from(PersonData::Full { name: "Alice".to_string(), age: 30, }); let person2 = Person::from(PersonData::NameOnly("Bob".to_string())); }
5. Использование дженериков
#![allow(unused)] fn main() { struct Container<T> { value: T, } impl<T> Container<T> { fn new(value: T) -> Self { Self { value } } } // Специализированные конструкторы для разных типов impl Container<String> { fn from_str(s: &str) -> Self { Self { value: s.to_string() } } } impl Container<i32> { fn from_f64(value: f64) -> Self { Self { value: value as i32 } } } // Использование let container1 = Container::new(42); let container2 = Container::new("hello".to_string()); let container3 = Container::from_str("world"); let container4 = Container::from_f64(3.14); }
6. Комбинация подходов
#![allow(unused)] fn main() { #[derive(Debug)] struct Config { host: String, port: u16, timeout: u64, retries: u32, } impl Config { // Базовый конструктор fn new(host: String, port: u16) -> Self { Self { host, port, timeout: 30, retries: 3, } } // Builder методы для опциональных полей fn timeout(mut self, timeout: u64) -> Self { self.timeout = timeout; self } fn retries(mut self, retries: u32) -> Self { self.retries = retries; self } } // Альтернативные конструкторы impl Config { fn from_url(url: &str) -> Option<Self> { let parts: Vec<&str> = url.split(':').collect(); if parts.len() == 2 { let host = parts[0].to_string(); let port = parts[1].parse().ok()?; Some(Self::new(host, port)) } else { None } } } // Использование let config1 = Config::new("localhost".to_string(), 8080) .timeout(60) .retries(5); let config2 = Config::from_url("example.com:443").unwrap(); }
Выбор подхода зависит от конкретной ситуации:
- Простые случаи - используйте именованные конструкторы
- Много опциональных параметров - используйте Builder паттерн
- Преобразование из разных типов - используйте From/TryFrom
- Разные варианты данных - используйте enum
- Обобщенные типы - используйте дженерики
3
В конструкторах не должно быть кода.
Подход направлен на создание стабильных, предсказуемых объектов. Конструктор становится простой операцией композиции, а вся сложная логика выносится в фабрики, билдеры или "умные" типы данных.
Объекты как неизменяемые сущности, которые либо валидны, либо не существуют вообще. Для агрументов используется NewType
Подход направлен против бизнес-логики в конструкторах доменных объектов, но допускаем код в конструкторах примитивных типов NewType.
Если в конструкторе есть код, то он может нарушать принцип единой ответственности: добавление валидаций, нормализаций, логирования. Сложно тестировать, когда логика в конструкторе.
Суть не в полном отсутствии кода, а в:
- Разделении ответственности - примитивы валидируют данные, доменные объекты реализуют бизнес-логику
- Предсказуемости - конструктор доменного объекта всегда работает
- Тестируемости - бизнес-логика вынесена в тестируемые компоненты
Разделение создания и бизнес-логики:
#![allow(unused)] fn main() { // Объект создания struct UserCreator; impl UserCreator { fn create(&self, name: &str, email: &str) -> Result<User, String> { // Вся сложная логика здесь self.validate_name(name)?; self.validate_email(email)?; let normalized_name = self.normalize_name(name); let normalized_email = self.normalize_email(email); Ok(User::new( UserName(normalized_name), Email(normalized_email) )) } fn validate_name(&self, name: &str) -> Result<(), String> { // логика валидации Ok(()) } // ... другие методы } }
Тестируемость:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[test] fn test_user_creation() { let name = UserName::new("test").unwrap(); let email = Email::new("test@test.com").unwrap(); let user = User::new(name, email); // Легко тестировать без побочных эффектов } } }
Если следовать принципу абсолютно строго, все конструкторы, даже NewType, не имеют код, а только присвоение и валидация просиходит в методах отдельных фабрик валидаторов.
4
Класс должен иметь/инкапсулировать максимум четыре обьекта. Это империческое наблюдение, как правило излишние обьекты говорят о недоработанности структуры обьектов.
5
Класс не инкапсулирующий ничего т.е. статический, это значит у него нет состояния и идентичности, только поведение.
Инкапсулированное состояние - это уникалтный идентификатор объекта.
Инстанцирование должно быть отделено от исполнения, что означает следующее: оператор new разрешен лишь в конструкторах.
Мы определенно можем создать объект, который ничего не инкапсулирует, и этому есть масса примеров. Но это неверно и с философской, и с практической точки зрения.
Суть инкапсуляции - в делегировании ответственности объекту. Таким образом объект получает право управлять своими (и не только) данными удобным для себя способом.
6
Всегда используйте интерфейсы
Объект живет в тесном социальном окружении. (Дергать каждый обьект за его уникальные "руки", не так эффективно, как через общий канал связи - контракт, интерфейс) Под этим я понимаю то, что объекты взаимосвязаны, поскольку они нуждаются друг в друге. В самом начале, когда мы точно знаем, что каждый объект должен делать и какие услуги предоставлять другим объектам, все просто. Но когда приложение начинает разрастаться и количество объектов превышает несколько десятков, тесная связь между ними становится серьезную и проблемой. И эта проблема влияет на сопровождаемость.
Чтобы повысить сопровождаемость приложения в целом,_мы должны приложить максимум усилий к расцеплению (decoupling) объекгов. Технически это означает возможность модифициропать объект, не модифицируя связанные с ним объекты. Лучший иструмент для этого - интерфейсы.
Удостоверьтесь, что все публичные методы класса реализуют какой-то интерфейс. Грамотно спроектированный класс не должен содер жать публичных методов, которые не реализуют хотя бы один интерфейс. Даже если в коде эти методы используются один раз но не забывайте про тестирование. Если вы пишете грамотные юнит-тесты, то для создания фиктивных объектов интерфейсы понадобятся там тоже.
8
Публичные константы в ООП - чистейшее зло, они не должны использоваться никогда. Всегда заменяйте их микроклассами. Неважно, насколько малы они будут. Не решайте проблему дублирования кода публичными константами - применяйте классы. Кстати, то же самое касается типов enum в Java, а в Rust enum это обьект с поведение так что это не примитивная константа. Перечисления ничем не отличаются от публичных констант, и их также необходимо избегать.
Тут про то что классы становятся зависимыми от публичных констант которые сами по себе не знают за что отвечают т.е. у них нет поведения.
Объекты не должны ничего использовать совместно - они должны быть самодостаточными и очень закрытыми. Механизм совмесгного использования противоречит идее инкапсуляции и объектно-ориентированному образу мышления в целом.
Почему классы использующие константы имею сцепление - если мы поменяем значение константы, то поведение использующих коснтанту классов изменится непредсказуемым образом. Почему непредсказуемым? Поскольку, когда мы меняем значение константы, мы понятия не имеим, как оно используется.
Потому что глобальная переменная противоречит DI (dependency injection). Использование глобальные переменных снижает возможность переиспользования кода.
Публичные константы в ООП это ПЛОХО, потому что:
- Неявная зависимость от глобального состояния
- Сложно тестировать (как подменить константу в тесте?)
- Нарушает инкапсуляцию
Альтернативы:
- Замена констант на объекты
- Конфигурация как зависимость
#![allow(unused)] fn main() { pub struct ApplicationConfig { user_limit: UserLimit, timeout: Timeout, api_url: ApiUrl, } impl ApplicationConfig { pub fn production() -> Self { Self { user_limit: UserLimit::new(100), timeout: Timeout::seconds(30), api_url: ApiUrl::new("https://api.example.com"), } } pub fn development() -> Self { Self { user_limit: UserLimit::new(10), timeout: Timeout::seconds(60), api_url: ApiUrl::new("https://dev-api.example.com"), } } } pub struct UserService { config: ApplicationConfig, } impl UserService { pub fn new(config: ApplicationConfig) -> Self { Self { config } } fn create_user(&self, user: User) -> Result<(), Error> { if self.user_count() >= self.config.user_limit.value() { return Err(Error::LimitExceeded); } // ... } } }
9
Изменяемые объекты - злоупотребление объектно-ориентиронанной парадигмой.
Бугаенко имеет ввиду под неизменяемыми обьектами понятие константные обьекты, эдиножды инстанцированные с набором данных они не должны иметь возможности менять исходные данные, так как эти данные были идентификатором, но они могут модифицировать эти данные например форматировать и т.д. это не влияет на состояние обьекта, а когда обьект позволяет заменить исходные данные то это меняет состояние, так как например изначально был текст одной книги и мы ее читали, а потом мы поменяли текст на другую книгу, это уже совсем другой обьект, другой идентификатор и это называется изменяемым обьект. (подробней в книге глава - Будьте лояльным и неизменяемым либо константным)
Объект который владеет файлом, эти данные являются его уникальным идентификатором, мы можем форматировать контент и это не меняет его сути, от этого он не становится изменяемым. А когда мы изменим данные этого объекта на другой файл, подменив файл, то объект уже совсем другой, у него другой идентификатор, так как данные другие. - это я так понимаю переменность, то когда мы так рассуждаем об изменяемости, тогда нам не нужно строить новый объект, мы просто изменим его версию (поле id пускай будет этим неизменяемым обьектом который надо создавать заново) и будем иметь в виду что это другой объект. И это уже будет ожидаемое поведение объекта чего мы и хотели достичь при поддержке с низкой когнитивной нагрузкой.
Бугаенко говорит, что изменяемые объекты не имеют права на существование. Их использование должно быть строго запрещено. Их просто не должно быть в ООП, как это сделано, например, в Haskell. Все классы должны инстанцировать неизменяемые объекты, которые никогда не меняют своего состояния вне зависимости от области применения, будь то игры, пользовательский интерфейс, мобильные или веб-приложения или даже алгоритмы.
"делайте классы неизменяемыми" это перегибает палку с неизменяемостью, так как:
- ❌ Непрактичен для high-performance кода
- ❌ Не работает с системным программированием
- ❌ Создает ненужные аллокации
- ❌ Усложняет код без реальной пользы
"делайте классы неизменяемыми" в Rust избыточен, потому что:
- Rust уже делает всё неизменяемым по умолчанию
- Система владения предотвращает случайные мутации
- Компилятор заставляет явно указывать mut
- Есть выбор между производительностью и безопасностью
10
Пишите тесты, а не документацию
Чтобы сделать свой код лучше читаемым, представьте, что я начинающий программист, слабо понимающий предметную область, язык программирования, шаблоны проектирования и алгоритмы. Представьте, что я намного глупее вас. Так вы демонстрируете свое уважение ко ·мне. Не хвастайтесь своими способностями - пишите простой легко читаемый код. Плохие программисты пишут сложный код. Хорошие программисты пишут простой код.
Мой вам совет: не документируйте код - делайте его чище. Под этим я, в частности, понимаю написание юнит-тестов. Юнит-тест должен рассматриваться как часть класса наравне с методами, свойствами, именем и перечнем реализу емых интерфейсов. Один юнит-тест стоит страницы документации. Юнит-тест показывает мне, как использовать класс, в то время как документация рассказывает историю, которую намного труднее понять и интерпретировать. Не говорите, а показывайте.
11
Используйте fаkе-объекты вместо mосk-объектов
Почему Бугаенко за fakes:
- Fakes тестируют поведение, а mocks - implementation details
- Mocks делают тесты хрупкими - при изменении внутренней реализации тесты ломаются. Он привязывает тесты к внутренним деталям реализации класса. Ставя тесты в зависимость от взаимодействия классов, мы делаем рефакторинг болезненным, а иногда и невозможным.
- Fakes ближе к реальности - тестируют то, что действительно важно для бизнеса
Практика в Rust, обычно используют оба подхода, но для разных целей:
- Fakes - для репозиториев, внешних сервисов, сложных зависимостей
- Mocks - для проверки специфических взаимодействий между компонентами
Fake - это рабочая, но упрощенная реализация, обычно для изоляции от внешних зависимостей:
#![allow(unused)] fn main() { // Настоящая база данных struct Database { /* сложная логика */ } // Fake-версия для тестов struct FakeDatabase { data: HashMap<String, String>, } impl FakeDatabase { fn new() -> Self { Self { data: HashMap::new() } } // Такие же методы как у настоящей БД, но работают в памяти fn save(&mut self, key: &str, value: &str) { self.data.insert(key.to_string(), value.to_string()); } fn load(&self, key: &str) -> Option<&str> { self.data.get(key).map(|s| s.as_str()) } } }
Mock - это "шпион", который записывает вызовы и проверяет взаимодействия:
#![allow(unused)] fn main() { use mockall::automock; #[automock] trait EmailService { fn send_email(&self, to: &str, subject: &str) -> Result<(), String>; } // В тестах let mut mock_email = MockEmailService::new(); mock_email.expect_send_email() .times(1) .returning(|_, _| Ok(())); // Тестируем, что send_email был вызван ровно 1 раз }
12
Делайте интерфейсы краткими, используйте smart-клaccы
Почему это работает в Rust
- Система типов позволяет создавать "умные" типы с гарантиями на уровне компиляции
- Traits естественно поддерживают разделение интерфейсов
- Newtype pattern идеально реализует smart-классы
Суть подхода
- Интерфейсы должны делать одну вещь (Single Responsibility)
- Типы должны гарантировать валидность на уровне компиляции
- Меньше методов = проще тестирование и использование
Этот раздел очень хорошо ложится на философию Rust!
Smart-классы вместо примитивов
Вместо примитивных типов в интерфейсах:
#![allow(unused)] fn main() { // ПЛОХО: примитивы в интерфейсе fn create_user(name: String, age: i32, email: String) -> Result<User, String> { if age < 0 || age > 150 { return Err("Invalid age".to_string()); } if !email.contains('@') { return Err("Invalid email".to_string()); } // ... } // ХОРОШО: smart-типы fn create_user(name: UserName, age: UserAge, email: Email) -> User { // Валидация уже выполнена в конструкторах типов User { name, age, email } } }
Краткие интерфейсы
Вместо монолитных интерфейсов:
#![allow(unused)] fn main() { // ПЛОХО: один интерфейс на все случаи trait MonsterDatabase { fn save_user(&self, user: User) -> Result<(), Error>; fn find_user(&self, id: UserId) -> Option<User>; fn update_user(&self, user: User) -> Result<(), Error>; fn delete_user(&self, id: UserId) -> Result<(), Error>; fn find_users_by_age(&self, age: u32) -> Vec<User>; // ... еще 20 методов } // ХОРОШО: несколько специализированных интерфейсов trait UserReader { fn find_user(&self, id: UserId) -> Option<User>; fn find_users_by_age(&self, age: u32) -> Vec<User>; } trait UserWriter { fn save_user(&self, user: User) -> Result<(), Error>; fn update_user(&self, user: User) -> Result<(), Error>; fn delete_user(&self, id: UserId) -> Result<(), Error>; } }
14
Короче говоря, данная глава посвящена аргументам против крупных объектов, статических объектов, NULL-ccылoк, геттеров, сеттеров и оператора new.
Не используйте статические методы
Бугаенко сожалеет обо всем том времени, которое потратил на написание процедурного, а не объектно-ориентированного программного обеспечения. Я был слеп, но теперь прозрел. Статические методы - настолько же большая, если не еще большая проблема в ООП, чем наличие константы NULL. Статические методы ухудшают сопровождаемость про граммного обеспечения.
При компьютерном образе мышления (процедурное) мы находимся у руля и контролируем поток исполнения инструкций, компьютер работает на нас, а мы указываем ему, что делать, давая ему явные инструкции. При объектно-ориентированном образе мышления мы просто определяем, кто есть кто, и пусть они взаимодействуют, когда это им понадобится.
Меня не особо беспокоит, что находится внутри объекта класса Мах и как именно он реализует интерфейс Number. Я не даю процессору инструкции относитель но этого вычисления. Я просто инстанцирую объект.
Напротив, статические методы в ООП - то же самое, что подпрограммы в С или ассемблере. Они не имеют отношения к ООП и заставляют нас писать процедурный код в объектно-ориенти рованном синтаксисе.
Статические методы имеют более высокую сцепленность, так как они не инкапсулируют поведение внутри объекта и напрямую связывают вызывающий код с их реализацией. Это делает их сложнее в сопровождении при изменении логики, в отличие от методов экземпляров, которые работают через инкапсуляцию объекта.
Императивный стиль нельзя совместить с декларативным чисто технически. Когда вы начинаете использовать императивный подход, вы обречены - постепенно весь ваш код станет императивным.
Статические методы напоминают раковую болезнь объектно ориентированного ПО: однажды позволив им поселиться в коде, мы не сможем избавиться от них - их колония будет только расти. Просто обходите их стороной в принципе.
Декларативный стиль против императивного
Пример Java функция интервал в императивном стиле:
public static int between(int 1, int r, int х) {
return Math.min(Math.max(l, х), r);
}
int у = Math.between(S, 9, 13);// сразу вычисляется
Пример Java функция интервал в декларативном стиле:
ciass Between implements Number {
private final Number num;
Between(Number left, Number right, Number х) {
this.num ~ new Min(new Max(left, х), right);
}
@Override
public int intValue() {
return this.num.intValue();
}
}
Number у = new Between(S, 9, 13); // еще не вычисляется!
Такой стиль будет декларативным, поскольку я не указываю процессору, что вычисления нужно выполнить сразу. Я просто определил, что это такое, и оставил на усмотрение пользователя решение о том, когда (и нужно ли вообще) вычислять переменную у методом intValue(). Я еще не дал никакой работы процессору. Как указано в определении, выразил логику, не описывая процесс.
Классы-утилиты
Классы-утилиты - триумф процедурных программистов в области объектно-ориентированного программирования. Класс утилита - не просто ужасная вещь вроде статического метода - это скопище ужасных вещей. Являются не классами, потому что из них нельзя инстанцировать объекты, они являются лишь набором статических методов.
Паперн «Синглтон»
«Синглтонэ известен как паттерн проектирования, но в действительности это ужасный антипаттерн. Есть масса причин того, почему это плохой прием программирования.
Зачем было изобретать синглтон, если у нас уже были статические методы и классы-утилиты? Я часто задаю этот вопрос на собеседованиях с Jаvа-программистами. Правда в следующем: синглтон намного лучше класса утилиты только потому, что позволяет заменить инкапсулируемый объект. В классе-утилите нет объекта - мы не можем ничего изменить. Класс-утилита - неразрывная жестко запрограммированная зависимость - чистейшее зло в ООП.
Моя точка зрения: синглтон нужен, в отличие от статического класса-утилиты, именно для того, чтобы разделять общее состояние между всеми. А его свойство объекта, выраженное в возможности подмены зависимости, уже вторично и присуще любым объектам.
Пример замены зависимости в Синглтонэ для целей тестирования:
Math math = new FakeMath();
Math.setinstance(math);
Utility classes:
В них обычно только статические методы — без состояния, без инкапсуляции, просто "набор функций в неймспейсе". Это фактически процедурное программирование с красивой обёрткой.
Проблема c классами-утилитами:
- Нет полиморфизма.
- Нельзя переопределить поведение.
- Нельзя хранить состояние.
Всё жёстко завязано на реализации → код плохо тестируется и расширяется.
Бугаенко рекомендует не использовать паттерн Синглтон, а вместо него применять инкапсуляцию, размещать нужный обьект во все используемые его классы как зависимость.
15
Не допускайте аргументов со значением NULL
NULL в аргуметах заставляет проверять эти аргументы перед использованием, что неправильно. Если обьект в аргументах есть, то мы с ним обращаемся изначально так, кем он является, и мы не должны устраивать ему проверки соответвия. В Rust для таких случаем есть явный тип Option, Result и мы знаем с кем имеет дело изначально, вся работа явная.
17
Никогда не используйте геттеры и сеттеры
Вот представитель изменяемого класса (против которых выступает Бугаенко). И вообще это не класс, а структура данных (DTO). Независимо от способа реализации геттеры и сеттеры являются данными и представляют данные, а не поведение.
class Cash {
private int dollars;
public int getDollars() {
return this.dollars;
}
public void setDollars(int value) {
this.dollars = value;
}
}
Почему быть структурой данных - грех в ООП. Потому что это не обьекты, у них нет инкапсуляции, и поведение с такой структурой как с простыми данными без поведения. И еще структуры данных повышают сцепленность и ухудшают сопровождаемость.
ООП было изобретено в первую очередь для того, чтобы упростить вещи по сравнению с процедурным миром. Объекты перевернули все с ног на голову. Код стал пассивным, а данные - активными. В этом суть ООП. Данные больше не сидят и ничего нe ждут. Теперь они инкапсулированы внутрь живых объектов. Они связаны друг с другом и, когда приходит время что-либо сделать, инициируют вызовы методов. Код больше не рулит. В ООП код вторичен. Объекты - полноправные граждане кода.
18
Не используйте оператор new вне вторичных конструкторов
Создание обьектов через оператор new создает жесткую зависимость.
Это не про композицию и агрегацию зависимостей и не про зависемость от абстракции (все же в какой-то мере про это), а про гибкость использования, если нет нужды в приватности зависимости (при создании или использовании) тогда не нужно ее прятать внутрь, дать возможность подменить зависимость.
Если вариант с внешней зависимостью с передачей в аргументы не подходит, то можно добавиит альтернативный метод с передачей зависимости в аргументы, а код с new оставить во вторичном конструкторе.
class Requests {
private final Socket socket;
private final Mapping<String, Request> mapping;
public Requests{Socket skt) {
this(
skt,
new Mapping<String, Request>() {
@Override
public Request map{String data) {
return new SimpleRequest{data);
}
}
);,
}
public Requests(Socket skt, Mapping<String, Request> mpg) {
this.socket = skt;
this.mapping = mpg;
}
public Request next() {
return this.mapping.map(
/* прочесть данные из сокета */
);
}
}
19
Избегайте интроспекции и приведения типов
Тут про вред рефлексии когда в языках Java, Javascript используют метод instanceof, который делает интроспекцию, проверяет на пренадлежность и на основании этого с этим обьектом взаимодействуют, что скртыно, неявно и ухудшает сопровождаемость. Код должен быть явным и описывать свое поведение в интерфейсе.
Пример посчета количества элементов, если обьект представитель Collection то использовать один спосо, но иначе по другому.
puЬlic <Т> int size(IteraЬle<T> items) {
if (items instanceof Collection) {
return Collection.class.cast(items).size();
}
int size = 0;
for (Т item: items) {
++size;
}
return size;
}
20
Никогда не возвращайте NULL
Нет ничего хуже для сопровождаемости, чем некорректная обработка исключений.
Исключения помогают передать проблему в корректное место ее исправления.
Речь идет про то что вместо не корретного значения NULL надо выбрасывать исключение. С точки зрения ООП, возвращать NULL это неверно, нужно изменять состояние обьекта, который должен быть всегда валидным. Мы взаимодействуем с состоянием обьекта, а не с проверкой NULL или обьект.
Если вам нужно вернуть что-то, что не было найдено, то либо бросьте исключение, либо верните коллекцию или пустой объект.
В Rust используется Result/Option в плане поведения с точки зрения ООП это тоже самое что NULL. Их тоже надо обрабатывать, вместо взаимодействия с состоянием обьекта, т.е. это уже процедурный код.
В слое бизнес логики лучше оперировать состоянием обьектов, а не проверками на Result/Option если мы придерживаемся стиля ООП, а в низкоуровневом слое который ближе к процедурному, нужно использовать Result/Option
#![allow(unused_variables)] use std::io; pub struct Input(String); impl Input{ pub fn new() -> Self{ Self("".to_owned()) } pub fn input(&mut self) -> io::Result<()>{ match io::stdin().read_line(&mut self.0) { Ok(_) => { if self.0.trim().is_empty(){ self.0="ошибка данных".to_owned(); } Ok(()) }, Err(e) => { self.0="ошибка данных".to_owned(); Err(e) }, } } pub fn show(&self){ println!("{}",self.0); } } fn input(buffer: &mut String) -> io::Result<()>{ io::stdin().read_line(buffer)?; Ok(()) } fn main() { // Возврат Option/Result это тоже самое что NULL let mut buffer = String::new(); let result = input(&mut buffer); match result { Ok(_) if !buffer.trim().is_empty() => { println!("{buffer}"); } _ => println!("ошибка данных"), }; // Со стороны ООП let mut input = Input::new(); input.input(); input.show(); }
21
Будьте либо константным, либо абстрактным
Есть два пути:
- Сделать метод либо полностью абстрактным, чтобы потомки реализовывали весь контракт.
- Сделать метод полностью константным, чтобы переопределение зависимых методов не влияло на него.
В Rust возможность переопределять методы трейтов создаёт ту же проблему, что и в Java с частично константными и частично абстрактными классами.
Переопределение зависимых методов изменит логику базового метода.
То есть:
- Есть методы с готовой логикой.
- Есть методы, которые можно переопределить в потомках.
- Потомки могут менять логику абстрактных методов.
- В результате базовый метод начинает вести себя по‑другому, что ломает принцип однозначности.
Это ведёт к двум проблемам:
-
Неявная ломка контракта — разработчик базового класса ожидает, что метод работает одним способом, но потомок меняет его поведение через переопределение зависимого метода.
-
Сложность поддержки — из‑за таких скрытых зависимостей сложно понять, как именно работает метод в каждом конкретном классе.
// В Java это выглядело бы как наследование, // где потомок переопределяет часть логики, // а базовый метод начинает работать по-другому, // что ведёт к нарушению принципа однозначности и предсказуемости. trait Base: Default { fn get_value(&self) -> &str; fn size(&self) -> usize { self.get_value().len() } fn g(&mut self) { *self = Self::default(); } } #[derive(Default)] struct Derived2(String); impl Base for Derived2 { fn get_value(&self) -> &str { &self.0 } fn g(&mut self) { self.0 = "Hello".to_owned(); } } #[derive(Default)] struct Derived(String); impl Base for Derived { fn get_value(&self) -> &str { &self.0 } } fn main() { let mut d = Derived::default(); d.g(); assert_eq!(d.size(), 0); let mut d2 = Derived2::default(); d2.g(); assert_eq!(d2.size(), 5);// ERROR ожидали размер значения от Default }