Перейти к содержанию

Принцип единственной ответственности

Принцип единой ответственности (Single Responsibility Principle) гласит, что у каждого класса должна быть только одна «ответственность» и он не должен брать на себя другие обязанности. Роберт К. Мартин объяснял его так: «У класса должна быть лишь одна причина для изменения». Давайте в качестве примера возьмем приложение телефонного справочника. Мы будем делать телефонный справочник, в котором будет класс TelephoneDirectory. Он будет «нести ответственность» за ведение записей справочника, то есть телефонных номеров и названий организаций, которым принадлежат номера. Ожидается, что класс будет выполнять следующие операции:

  • добавлять новую запись (Name и Telephone Number)
  • удалять существующую запись
  • изменять номер телефона, присвоенный сущности Name
  • предоставлять поиск, который будет возвращать номер, присвоенный сущности Name.
  • Класс TelephoneDirectory может выглядеть следующим образом:
class TelephoneDirectory:
    def __init__(self):
        self.telephone_directory = dict()

    def add_entry(self, name, number):
        self.telephone_directory[name] = number

    def delete_entry(self, name):
        del self.telephone_directory[name]

    def update_entry(self, name, number):
        self.telephone_directory[name] = number

    def lookup_number(self, name):
        return self.telephone_directory.get(name)

    def __str__(self):
        ret_dct = ''
        for key, value in self.telephone_directory.items():
            ret_dct += f'{key} : {value}\n'
        return ret_dct

phone_book = TelephoneDirectory()
phone_book.add_entry('Ravi', 123456)
phone_book.add_entry('Vikas', 67890)
print(phone_book)

phone_book.delete_entry('Ravi')
phone_book.add_entry('Ravi', 123456)
phone_book.update_entry('Vikas', 776589)
print(phone_book.lookup_number('Vikas'))
print(phone_book)
<?php
class TelephoneDirectory {
    private array $telephone_directory = [];

    public function add_entry(string $name, string $number) {
        $this->telephone_directory[$name] = $number;
    }

    public function delete_entry(string $name) {
        if (array_key_exists($name, $this->$telephone_directory)) {
            unset($this->telephone_directory[$name]);
        }
    }

    public function update_entry(string $name, string $number) {
        $this->telephone_directory[$name] = $number;
    }

    public function lookup_number(string $name) {
        if (array_key_exists($name, $this->$telephone_directory)) {
            return $this->telephone_directory[$name];
        }
        return null;
    }

    public function __toString() {
        $data = [];
        foreach ($this->$telephone_directory as $name => $phone) {
            $data[] = $name . ' : ' . $phone;
        }
        return implode(PHP_EOL, $data);
    }
}

$phone_book = new TelephoneDirectory()
$phone_book->add_entry('Ravi', 123456)
$phone_book->add_entry('Vikas', 67890)
echo $phone_book;
echo PHP_EOL;

$phone_book->delete_entry('Ravi')
$phone_book->add_entry('Ravi', 123456)
$phone_book->update_entry('Vikas', 776589)
echo $phone_book->lookup_number('Vikas');
echo PHP_EOL;
echo $phone_book;
Ravi : 123456
Vikas : 67890

776589
Vikas : 776589
Ravi : 123456

Сейчас наш класс TelephoneDirectory выглядит хорошо, в нем точно реализованы ожидаемые функции: А теперь скажем, что в проекте есть еще два требования – Сохранить содержимое справочника в базе данных и перенести содержимое справочника в файл. Теперь добавим еще два метода в класс TelephoneDirectory, как показано ниже:

class TelephoneDirectory:
    def __init__(self):
        self.telephone_directory = dict()

    def add_entry(self, name, number):
        self.telephone_directory[name] = number

    def delete_entry(self, name):
        del self.telephone_directory[name]

    def update_entry(self, name, number):
        self.telephone_directory[name] = number

    def lookup_number(self, name):
        return self.telephone_directory.get(name)

    def save_to_file(self, file_name, location):
        #код для сохранения телефонного справочника в файл
        pass

    def persist_to_database(self, database_details):
        #код для сохранения телефонного справочника в базу данных
        pass

    def __str__(self):
        ret_dct = ''
        for key, value in self.telephone_directory.items():
            ret_dct += f'{key} : {value}\n'
        return ret_dct
<?php
class TelephoneDirectory {
    private array $telephone_directory = [];

    public function add_entry(string $name, string $number) {
        $this->telephone_directory[$name] = $number;
    }

    public function delete_entry(string $name) {
        if (array_key_exists($name, $this->$telephone_directory)) {
            unset($this->telephone_directory[$name]);
        }
    }

    public function update_entry(string $name, string $number) {
        $this->telephone_directory[$name] = $number;
    }

    public function lookup_number(string $name) {
        if (array_key_exists($name, $this->$telephone_directory)) {
            return $this->telephone_directory[$name];
        }
        return null;
    }

    public function __toString() {
        $data = [];
        foreach ($this->$telephone_directory as $name => $phone) {
            $data[] = $name . ' : ' . $phone;
        }
        return implode(PHP_EOL, $data);
    }

    public function save_to_file(string $file_name, string $location) {
        // код для сохранения телефонного справочника в файл
    }

    public function persist_to_database(string $database_details) {
        // код для сохранения телефонного справочника в базу данных
    }
}

Так вот, именно сейчас мы нарушили принцип единственной ответственности. Добавив функции сохранения в базу данных и сохранения в файл, мы дали классу дополнительные обязанности, которые не входят в его основную зону ответственности. Теперь в классе есть дополнительные функции, которые могут привести к его изменению. В будущем, если появятся какие-то требования, связанные с сохранением данных, это может привести к изменениям в классе TelephoneDirectory. Получается, что класс TelephoneDirectory подвержен изменениям по причинам, которые не являются его основной ответственностью. Принцип единственной ответственности требует от нас не добавлять дополнительные обязанности к классу, чтобы нам не приходилось менять класс, когда нам нужно изменить функционал сохранения справочника в базу данных или в файл. Мы можем передать экземпляр класса TelephoneDirectory экземплярам этих классов и записать любые дополнительные функции в них. Так мы гарантируем, что у класса TelephoneDirectory есть лишь одна причина для изменения – это изменения в его основной «ответственности».

1
2
3
4
5
6
7
class PersistToDatabase:
    def __init__(self, object_to_persist):
        pass

class SaveToFile:
    def __init__(self, object_to_save):
        pass
<?php
class PersistToDatabase {
    public function __construct (TelephoneDirectory $object_to_persist) {
        // сохранение в базу данных
    }
}

class SaveToFile {
    public function __construct (TelephoneDirectory $object_to_save) {
        // сохранение в файл
    }
}

Задачи

Задача 1. Разделение класса пользователя

Дан класс User, который хранит данные пользователя, проверяет email и отправляет приветственное письмо. Нарушает ли он SRP? Разбейте его на классы с единственной ответственностью.

# Нарушение SRP — один класс делает слишком много
class User:
    def __init__(self, name: str, email: str):
        self.name = name
        self.email = email

    def validate_email(self) -> bool:
        return '@' in self.email

    def send_welcome_email(self):
        print(f"Отправка письма на {self.email}: Добро пожаловать, {self.name}!")

# Вызов
user = User("Алексей", "[email protected]")
user.send_welcome_email()
<?php
// Нарушение SRP — один класс делает слишком много
class User {
    public string $name;
    public string $email;

    public function __construct(string $name, string $email) {
        $this->name = $name;
        $this->email = $email;
    }

    public function validate_email(): bool {
        return str_contains($this->email, '@');
    }

    public function send_welcome_email(): void {
        echo "Отправка письма на {$this->email}: Добро пожаловать, {$this->name}!\n";
    }
}

// Вызов
$user = new User("Алексей", "[email protected]");
$user->send_welcome_email();

Задача 2. Отчёт о продажах

Класс SalesReport формирует данные отчёта и сразу выводит его в HTML. Выделите форматирование в отдельный класс.

# Нарушение SRP
class SalesReport:
    def __init__(self, data: list[dict]):
        self.data = data

    def get_total(self) -> float:
        return sum(item['amount'] for item in self.data)

    def render_html(self) -> str:
        rows = ''.join(f"<tr><td>{d['name']}</td><td>{d['amount']}</td></tr>"
                       for d in self.data)
        return f"<table>{rows}<tr><td>Итого</td><td>{self.get_total()}</td></tr></table>"

# Вызов
report = SalesReport([
    {'name': 'Товар A', 'amount': 1500},
    {'name': 'Товар B', 'amount': 2300},
])
print(report.render_html())
<?php
// Нарушение SRP
class SalesReport {
    public function __construct(private array $data) {}

    public function get_total(): float {
        return array_sum(array_column($this->data, 'amount'));
    }

    public function render_html(): string {
        $rows = '';
        foreach ($this->data as $d) {
            $rows .= "<tr><td>{$d['name']}</td><td>{$d['amount']}</td></tr>";
        }
        $total = $this->get_total();
        return "<table>{$rows}<tr><td>Итого</td><td>{$total}</td></tr></table>";
    }
}

// Вызов
$report = new SalesReport([
    ['name' => 'Товар A', 'amount' => 1500],
    ['name' => 'Товар B', 'amount' => 2300],
]);
echo $report->render_html();

Задача 3. Заказ в интернет-магазине

Класс Order хранит позиции заказа, считает стоимость и логирует действия. Найдите нарушение SRP и исправьте его.

# Нарушение SRP
class Order:
    def __init__(self):
        self.items = []

    def add_item(self, name: str, price: float, qty: int):
        self.items.append({'name': name, 'price': price, 'qty': qty})
        print(f"[LOG] Добавлен товар: {name}, кол-во: {qty}")

    def get_total(self) -> float:
        total = sum(i['price'] * i['qty'] for i in self.items)
        print(f"[LOG] Подсчёт итога: {total}")
        return total

# Вызов
order = Order()
order.add_item("Книга", 350.0, 2)
order.add_item("Ручка", 50.0, 5)
print(f"Итого: {order.get_total()}")
<?php
// Нарушение SRP
class Order {
    private array $items = [];

    public function add_item(string $name, float $price, int $qty): void {
        $this->items[] = compact('name', 'price', 'qty');
        echo "[LOG] Добавлен товар: {$name}, кол-во: {$qty}\n";
    }

    public function get_total(): float {
        $total = array_sum(array_map(fn($i) => $i['price'] * $i['qty'], $this->items));
        echo "[LOG] Подсчёт итога: {$total}\n";
        return $total;
    }
}

// Вызов
$order = new Order();
$order->add_item("Книга", 350.0, 2);
$order->add_item("Ручка", 50.0, 5);
echo "Итого: " . $order->get_total() . "\n";

Задача 4. Аутентификация пользователя

Класс AuthService проверяет пароль, генерирует JWT-токен и сохраняет сессию в базу данных. Приведите его в соответствие с SRP.

import hashlib, secrets

# Нарушение SRP
class AuthService:
    def check_password(self, plain: str, hashed: str) -> bool:
        return hashlib.sha256(plain.encode()).hexdigest() == hashed

    def generate_token(self) -> str:
        return secrets.token_hex(32)

    def save_session(self, user_id: int, token: str):
        print(f"[DB] Сессия сохранена: user_id={user_id}, token={token[:8]}...")

# Вызов
auth = AuthService()
stored_hash = hashlib.sha256("secret".encode()).hexdigest()
if auth.check_password("secret", stored_hash):
    token = auth.generate_token()
    auth.save_session(42, token)
    print(f"Токен: {token[:8]}...")
<?php
// Нарушение SRP
class AuthService {
    public function check_password(string $plain, string $hashed): bool {
        return hash('sha256', $plain) === $hashed;
    }

    public function generate_token(): string {
        return bin2hex(random_bytes(32));
    }

    public function save_session(int $user_id, string $token): void {
        echo "[DB] Сессия сохранена: user_id={$user_id}, token=" . substr($token, 0, 8) . "...\n";
    }
}

// Вызов
$auth = new AuthService();
$stored_hash = hash('sha256', 'secret');
if ($auth->check_password('secret', $stored_hash)) {
    $token = $auth->generate_token();
    $auth->save_session(42, $token);
    echo "Токен: " . substr($token, 0, 8) . "...\n";
}

Задача 5. Парсер CSV-файла

Класс CsvProcessor читает CSV-файл, парсит строки и сразу записывает результат в новый файл. Разделите обязанности согласно SRP.

import csv, io

# Нарушение SRP
class CsvProcessor:
    def process(self, csv_content: str, output_path: str):
        reader = csv.DictReader(io.StringIO(csv_content))
        rows = [row for row in reader]
        # сразу пишем в файл
        with open(output_path, 'w', newline='') as f:
            writer = csv.DictWriter(f, fieldnames=rows[0].keys())
            writer.writeheader()
            writer.writerows(rows)
        return rows

# Вызов
content = "name,age\nАнна,30\nИван,25"
processor = CsvProcessor()
data = processor.process(content, "/tmp/output.csv")
for row in data:
    print(row)
<?php
// Нарушение SRP
class CsvProcessor {
    public function process(
        string $csv_content,
        string $output_path
    ): array {
        $lines = explode("\n", trim($csv_content));
        $headers = str_getcsv(array_shift($lines));
        $rows = array_map(function($l){
            return array_combine($headers, str_getcsv($l))
        }, $lines);
        // сразу пишем в файл
        $handle = fopen($output_path, 'w');
        fputcsv($handle, $headers);
        foreach ($rows as $row) {
            fputcsv($handle, $row);
        }
        fclose($handle);
        return $rows;
    }
}

// Вызов
$content = "name,age\nАнна,30\nИван,25";
$processor = new CsvProcessor();
$data = $processor->process($content, "/tmp/output.csv");
foreach ($data as $row) {
    echo implode(', ', $row) . "\n";
}