Patterns of Programming

Статьи в секции:

  • Newtype

    Newtype Pattern

  • SRP

    Single Responsibility Principle

Struct of Arrays (SoA)

Если нужно работать с штучним объектом, вдобавок менять у него сразу много полей, то лучше SoA. Use case: RPG игра (тыкаешь в одного персонажа), БД (достаешь одну запись), редактор (меняешь один объект):

// Работа с ОДНИМ объектом
fn feed_oldest_animal(animals: &mut [Animal]) {
    let oldest = &mut animals[0];  // берем 1 животное
    oldest.health += 10.0;         // работаем со всеми полями сразу
    oldest.hunger -= 5.0;
}

ECS: Data-Oriented Design

Сложно поддерживать код SoA. На помощь в Rust приходят ECS (Entity Component System). Примеры - Bevy ECS, Hecs, Legion. ECS абстрагирует работу с памятью. Вы пишете компоненты как маленькие структуры:

struct Position { x: f32, y: f32 }
struct Velocity { dx: f32, dy: f32 }

А затем пишете Системы (функции), которые запрашивают только то, что им нужно:

// Пример из Bevy ECS
fn physics_system(mut query: Query<(&mut Position, &Velocity)>) {
    for (mut pos, vel) in query.iter_mut() {
        pos.x += vel.dx;
        pos.y += vel.dy;
    }
}

ECS-фреймворк сам раскладывает компоненты в памяти в виде плотных массивов (почти как SoA, чаще всего используя паттерн Archetypes). Когда physics_system запрашивает данные, то бежит по памяти линейно, идеально утилизируя кэш.

Array of Structs (AoS)

Если надо сделать массовую операцию, то лучше AoS. Use cases: 3D-графика (двигаем 10,000 частиц), нейросети (матричные операции), аудиообработка (тысячи сэмплов):

// Обновляем всех сразу
fn update_all_positions(particles: &mut Particles) {
    // Векторизация! +SIMD ускоряет +х10 скорости
    for i in 0..particles.positions.len() {
        particles.positions[i].x += particles.velocities[i].x;
    }
}

“Hybrid” Arrays (AoSoA)

А есть вариант менять объекты подмножествами. То есть по несколько штук, но не все. Тогда гибридный подход AoSoA. Use case: комп игры, 3D-графика (эмиттеры частиц):

// Группируем по 8 частиц в "пачку"
struct ParticlePack {
    x: [f32; 8],   // 8 позиций X
    y: [f32; 8],   // 8 позиций Y
    vx: [f32; 8],  // 8 скоростей X
}
let mut game: Vec<ParticlePack>; // массив пачек
  • Можно удалять/добавлять пачками (удобно);
  • Внутри пачки — SoA для скорости;
  • Размер пачки = размер кэш-линии (64 байта).

Subsections of Patterns of Programming

Newtype

Суть

Придание дополнительного смысла типу переменной. Например, строка String может быть использована для чего угодно, а нам нужно конкретизировать:

// Обычный String
let name = String::from("john");

// Newtypes - каждый тип уникален
struct Username(String);
struct Email(String);
struct Password(String);

let user = Username(String::from("john"));  // Не перепутать типы
let email = Email(String::from("john@example.com"));  // Они разные!

У данного паттерна НЕТ стоимости в скорости или ресурсах приложения.

Применение

Защита от смешивания типов:

struct Meters(f32);
struct Kilometers(f32);

fn race_distance(distance: Meters) {
    println!("Race is {} meters!", distance.0);
}

let m = Meters(100.0);
let km = Kilometers(0.1);

race_distance(m);     // ✅ Работает!
race_distance(km);    // ❌ ОШИБКА! Нельзя передать КМ туда, где ждут М

Дополнительные методы:

struct Age(u8);

impl Age {
    fn is_adult(&self) -> bool {
        self.0 >= 18
    }
    
    fn can_retire(&self) -> bool {
        self.0 >= 65
    }
}

let my_age = Age(16);
println!("Adult? {}", my_age.is_adult());  // false

Спрятать детали реализации:

pub struct CreditCardNumber(String);  // клиенты видят этот метод

impl CreditCardNumber {
    // Отдаём только безопасные методы
    pub fn last_four(&self) -> String {
        self.0[self.0.len()-4..].to_string()
    }
    
    // Внутренняя валидация происходит тут:
    pub fn new(number: String) -> Result<Self, String> {
        if number.len() == 16 {
            Ok(CreditCardNumber(number))
        } else {
            Err("Must be 16 digits".to_string())
        }
    }
}

// Нельзя извне обратиться к сырой строке!
let card = CreditCardNumber::new("1234567890123456".to_string()).unwrap();
println!("{}", card.last_four());  // "3456" ✅
// println!("{}", card.0);  ❌ ОШИБКА! Приватное поле!

Когда использовать паттерн Newtype

  • Когда есть два значения одного типа, но означают разные вещи (IDs, измерения);
  • Когда надо добавить методы к типу, которым мы не владеем (например, обернуть u32, чтобы добавить is_even());
  • Когда надо применить правила валидации.

Когда НЕ использовать паттерн Newtype

  • Когда действительно нужны все методы исходного типа (вместо этого используйте псевдоним типа: type Age = u8).

SRP

Принцип

У каждого класса/модуля/функции должна быть только одна причина для изменения.

Пример из жизни: кофемашина. Если она варит кофе, сама себя чистит, сама заказывает зёрна через интернет, печатает чеки - то это плохо! Если изменится способ печати чеков - придётся менять кофемашину. Если поставщик зёрен поменяет API - снова менять кофемашину. Правильно: кофемашина только варит кофе. Чисткой занимается отдельный сотрудник, заказом зёрен — менеджер, печатью чеков — кассовый аппарат.

Пример антипаттерна в коде: SmartDevice::print_state() и хранит температуру, и выводит её в консоль:

impl SmartDevice {
    pub fn print_state(&self) {
        println!("Текущая температура: {:.1}°C", ...);  // привязка к stdout
    }
}

Если потом потребуется писать отчёты в файл, отправлять по сети, выводить на веб-страницу, логировать в JSON, то придётся менять сам доменный тип, и это плохо! ПравильноSmartDevice только хранит данные и отвечает за свою бизнес-логику. А за вывод отвечает кто-то другой.

Антипаттерн без Display:

struct Person {
    name: String,
    age: u8,
}

impl Person {
/// SRP нарушен: тип сам знает, как выводить в консоль
    pub fn print_info(&self) {
        println!("Person: {} Age: {}", self.name, self.age);
    }
}

fn main() {
    let person = Person {
        name: "Alex".into(),
        age: 20,
    };

    person.print_info(); // вывод только в консоль 
}

Паттерн SRP с Display:

use std::fmt;
struct Person {
    name: String,
    age: u8,
}

impl fmt::Display for Person {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{} {}", self.name, self.age)
    }
}

fn main() {
    let person = Person {
        name: "Alex".into(),
        age: 20,
    };

    println!("{}", person); // вывод в консоль
    let text = format!("{}", person); // вывод в строку
    std::fs::write("out.txt", text).unwrap(); // вывод в файл
}

Что такое f: &mut fmt::Formatter<'_>:

  • Содержание (self - то, что надо “напечатать”)
  • Лист бумаги (f - куда Вы это записываете) fmt::Formatter - это как лист бумаги + ручка в одном флаконе. Он знает:
  • куда писать (в консоль, в строку, в файл)
  • какие настройки форматирования (ширина, выравнивание и т.д.)

Что происходит в println:

  1. println! создаёт свой Formatter, который указывает на стандартный вывод
  2. Вызывает person.fmt(&mut formatter)
  3. Метод fmt пишет в этот formatter
  4. println! добавляет перевод строки и отправляет в консоль