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

Принцип открытости/закрытости

Принцип открытости/закрытости (Open‐Closed Principle) впервые был сформулирован Бернардом Мейером в 1988 году. Роберт К. Мартин говорил о нем так «Наиболее важный принцип открытости/закрытости гласит «Сущности программы (классы, модули, функции и т.п.) должны быть открыты для расширения, но закрыты для изменений». Следование этому принципу гарантирует, что класс определен достаточно, чтобы делать то, что он должен делать. Добавление любых дополнительных функций может быть реализовано путем создания новых сущностей, которые расширяют возможности существующего класса и добавляют дополнительные функции самим себе. Таким образом можно предотвратить частые и тривиальные изменения в хорошо зарекомендовавшем себя классе низкого уровня. Допустим, у нас есть приложение для магазина одежды. Среди функций системы есть функция применения специальных скидок в зависимости от типа одежды. Пример ниже показывает один из способов реализации этого требования. В примере у нас есть класс DiscountCalculator, который умеет хранить тип одежды. В нем есть функция, которая рассчитывает скидку в зависимости от типа одежды и возвращает новую стоимость за вычетом суммы скидки.

from enum import Enum

class Products(Enum):
    SHIRT = 1
    TSHIRT = 2
    PANT = 3


class DiscountCalculator():
    def __init__(self, product_type, cost):
        self.product_type = product_type
        self.cost = cost

    def get_discounted_price(self):
        if self.product_type == Products.SHIRT:
            return self.cost - (self.cost * 0.10)
        elif self.product_type == Products.TSHIRT:
            return self.cost - (self.cost * 0.15)
        elif self.product_type == Products.PANT:
            return self.cost - (self.cost * 0.25)


dc_shirt = DiscountCalculator(Products.SHIRT, 100)
print(dc_shirt.get_discounted_price())

dc_tshirt = DiscountCalculator(Products.TSHIRT, 100)
print(dc_tshirt.get_discounted_price())

dc_pant = DiscountCalculator(Products.PANT, 100)
print(dc_pant.get_discounted_price())
<?php
enum Products
{
    case SHIRT;
    case TSHIRT;
    case PANT;
}

class DiscountCalculator {
    private Products $product_type;
    private float $cost = 0;

    public function __construct(Products $product_type, float $cost = 0) {
        $this->product_type = $product_type;
        $this->cost = $cost;
    }

    public function get_discounted_price() {
        if ($this->product_type === Products::SHIRT) {
            return $this->cost - ($this->cost * 0.10);
        }
        if ($this->product_type === Products::TSHIRT) {
            return $this->cost - ($this->cost * 0.15);
        }

        if ($this->product_type === Products::PANT) {
            return $this->cost - ($this->cost * 0.25);
        }
    }
}

$dc_shirt = new DiscountCalculator(Products::SHIRT, 100)
echo $dc_shirt->get_discounted_price() . PHP_EOL;

$dc_tshirt = new DiscountCalculator(Products::TSHIRT, 100)
echo $dc_tshirt->get_discounted_price() . PHP_EOL;

$dc_pant = new DiscountCalculator(Products::PANT, 100)
echo $dc_pant->get_discounted_price() . PHP_EOL;
90.0
85.0
75.0

Эта конструкция нарушает принцип открытости/закрытости, поскольку этот класс потребует изменения, если будет добавляться какой-то тип одежды или если сумма скидки на какую-либо одежду изменится.

from abc import abstractmethod

class DiscountCalculator():

    @abstractmethod
    def get_discounted_price(self):
        pass

class DiscountCalculatorShirt(DiscountCalculator):
    def __init__(self, cost):
        self.cost = cost

    def get_discounted_price(self):
        return self.cost - (self.cost * 0.10)

class DiscountCalculatorTshirt(DiscountCalculator):
    def __init__(self, cost):
        self.cost = cost

    def get_discounted_price(self):
        return self.cost - (self.cost * 0.15)

class DiscountCalculatorPant(DiscountCalculator):
    def __init__(self, cost):
        self.cost = cost

    def get_discounted_price(self):
        return self.cost - (self.cost * 0.25)


dc_shirt = DiscountCalculatorShirt(100)
print(dc_shirt.get_discounted_price())

dc_tshirt = DiscountCalculatorTshirt(100)
print(dc_tshirt.get_discounted_price())

dc_pant = DiscountCalculatorPant(100)
print(dc_pant.get_discounted_price())
<?php
interface DiscountCalculator {
    public function get_discounted_price();
}

abstract class AbstractDiscountCalculator {
    protected float $cost = 0;

    public function __construct(float $cost = 0) {
        $this->cost = $cost;
    }
}

class DiscountCalculatorShirt extends AbstractDiscountCalculator implements DiscountCalculator {

    public function get_discounted_price() {
        return $this->cost * 0.9;
    }
}

class DiscountCalculatorTshirt extends AbstractDiscountCalculator implements DiscountCalculator {

    public function get_discounted_price() {
        return $this->cost * 0.85;
    }
}

class DiscountCalculatorPant extends AbstractDiscountCalculator implements DiscountCalculator {

    public function get_discounted_price() {
        return $this->cost * 0.75;
    }
}

$dc_shirt = new DiscountCalculatorShirt(100);
echo $dc_shirt->get_discounted_price() . PHP_EOL;

$dc_tshirt = new DiscountCalculatorTshirt(100);
echo $dc_tshirt->get_discounted_price() . PHP_EOL;

$dc_pant = new DiscountCalculatorPant(100);
echo $dc_pant->get_discounted_price() . PHP_EOL;
90.0
85.0
75.0

Как видно из примера выше, теперь у нас есть очень простой базовый класс DiscountCalculator с одним абстрактным методом get_discounted_price. Мы создали новые классы для одежды, которые расширяют базовый класс DiscountCalculator. Следовательно, теперь каждый подкласс будет реализовывать функционал скидок самостоятельно. Сделав так, мы устранили предыдущие ограничения, которые требовали внесения изменений в базовый класс. Теперь, не изменяя базовый класс, мы можем добавлять больше одежды, а также изменять размер скидки на отдельный вид одежды по мере необходимости.

Задачи

Задача 1. Расчёт площади фигур

Дан класс AreaCalculator, который считает площадь разных фигур через if/elif. Нарушает ли он OCP? Добавьте поддержку треугольника, не изменяя существующий код.

# Нарушение OCP — при добавлении фигуры нужно менять этот класс
class AreaCalculator:
    def calculate(self, shape: dict) -> float:
        if shape['type'] == 'circle':
            return 3.14159 * shape['radius'] ** 2
        elif shape['type'] == 'rectangle':
            return shape['width'] * shape['height']

# Вызов
calc = AreaCalculator()
print(calc.calculate({'type': 'circle', 'radius': 5}))
print(calc.calculate({'type': 'rectangle', 'width': 4, 'height': 6}))
<?php
// Нарушение OCP — при добавлении фигуры нужно менять этот класс
class AreaCalculator {
    public function calculate(array $shape): float {
        if ($shape['type'] === 'circle') {
            return M_PI * $shape['radius'] ** 2;
        } elseif ($shape['type'] === 'rectangle') {
            return $shape['width'] * $shape['height'];
        }
        return 0;
    }
}

// Вызов
$calc = new AreaCalculator();
echo $calc->calculate(['type' => 'circle', 'radius' => 5]) . PHP_EOL;
echo $calc->calculate(['type' => 'rectangle', 'width' => 4, 'height' => 6]) . PHP_EOL;

Задача 2. Экспорт данных

Класс DataExporter экспортирует данные в разные форматы (JSON, CSV) через условные операторы. Перепроектируйте так, чтобы добавление нового формата (например, XML) не требовало изменения DataExporter.

import json, csv, io

# Нарушение OCP
class DataExporter:
    def export(self, data: list[dict], format: str) -> str:
        if format == 'json':
            return json.dumps(data, ensure_ascii=False)
        elif format == 'csv':
            output = io.StringIO()
            writer = csv.DictWriter(output, fieldnames=data[0].keys())
            writer.writeheader()
            writer.writerows(data)
            return output.getvalue()

# Вызов
exporter = DataExporter()
records = [{'name': 'Анна', 'age': 30}, {'name': 'Иван', 'age': 25}]
print(exporter.export(records, 'json'))
print(exporter.export(records, 'csv'))
<?php
// Нарушение OCP
class DataExporter {
    public function export(array $data, string $format): string {
        if ($format === 'json') {
            return json_encode($data, JSON_UNESCAPED_UNICODE);
        } elseif ($format === 'csv') {
            $handle = fopen('php://memory', 'r+');
            fputcsv($handle, array_keys($data[0]));
            foreach ($data as $row) {
                fputcsv($handle, $row);
            }
            rewind($handle);
            return stream_get_contents($handle);
        }
        return '';
    }
}

// Вызов
$exporter = new DataExporter();
$records = [['name' => 'Анна', 'age' => 30], ['name' => 'Иван', 'age' => 25]];
echo $exporter->export($records, 'json') . PHP_EOL;
echo $exporter->export($records, 'csv');

Задача 3. Уведомления

Класс NotificationService отправляет уведомления по email или SMS в зависимости от параметра. Перепроектируйте с учётом OCP, чтобы можно было добавить push-уведомления без изменения NotificationService.

# Нарушение OCP
class NotificationService:
    def send(self, channel: str, recipient: str, message: str):
        if channel == 'email':
            print(f"Email → {recipient}: {message}")
        elif channel == 'sms':
            print(f"SMS → {recipient}: {message}")

# Вызов
service = NotificationService()
service.send('email', '[email protected]', 'Ваш заказ подтверждён')
service.send('sms', '+79001234567', 'Код подтверждения: 1234')
<?php
// Нарушение OCP
class NotificationService {
    public function send(string $channel, string $recipient, string $message): void {
        if ($channel === 'email') {
            echo "Email → {$recipient}: {$message}\n";
        } elseif ($channel === 'sms') {
            echo "SMS → {$recipient}: {$message}\n";
        }
    }
}

// Вызов
$service = new NotificationService();
$service->send('email', '[email protected]', 'Ваш заказ подтверждён');
$service->send('sms', '+79001234567', 'Код подтверждения: 1234');

Задача 4. Сортировка коллекции

Класс Sorter сортирует массив разными алгоритмами, выбираемыми через строковый параметр. Перепроектируйте так, чтобы новый алгоритм добавлялся без изменения класса Sorter.

# Нарушение OCP
class Sorter:
    def sort(self, data: list, algorithm: str) -> list:
        if algorithm == 'bubble':
            arr = data[:]
            for i in range(len(arr)):
                for j in range(len(arr) - i - 1):
                    if arr[j] > arr[j + 1]:
                        arr[j], arr[j + 1] = arr[j + 1], arr[j]
            return arr
        elif algorithm == 'builtin':
            return sorted(data)

# Вызов
sorter = Sorter()
numbers = [5, 3, 8, 1, 9, 2]
print(sorter.sort(numbers, 'bubble'))
print(sorter.sort(numbers, 'builtin'))
<?php
// Нарушение OCP
class Sorter {
    public function sort(array $data, string $algorithm): array {
        if ($algorithm === 'bubble') {
            $arr = $data;
            $n = count($arr);
            for ($i = 0; $i < $n; $i++) {
                for ($j = 0; $j < $n - $i - 1; $j++) {
                    if ($arr[$j] > $arr[$j + 1]) {
                        [$arr[$j], $arr[$j + 1]] = [$arr[$j + 1], $arr[$j]];
                    }
                }
            }
            return $arr;
        } elseif ($algorithm === 'builtin') {
            sort($data);
            return $data;
        }
        return $data;
    }
}

// Вызов
$sorter = new Sorter();
$numbers = [5, 3, 8, 1, 9, 2];
print_r($sorter->sort($numbers, 'bubble'));
print_r($sorter->sort($numbers, 'builtin'));

Задача 5. Обработка платежей

Класс PaymentProcessor обрабатывает платежи через разные системы (карта, криптовалюта) с помощью условных операторов. Добавьте поддержку оплаты через СБП, не изменяя PaymentProcessor.

# Нарушение OCP
class PaymentProcessor:
    def pay(self, method: str, amount: float):
        if method == 'card':
            print(f"Оплата картой: {amount} руб.")
        elif method == 'crypto':
            print(f"Оплата криптовалютой: {amount} руб.")

# Вызов
processor = PaymentProcessor()
processor.pay('card', 1500.0)
processor.pay('crypto', 3200.0)
<?php
// Нарушение OCP
class PaymentProcessor {
    public function pay(string $method, float $amount): void {
        if ($method === 'card') {
            echo "Оплата картой: {$amount} руб.\n";
        } elseif ($method === 'crypto') {
            echo "Оплата криптовалютой: {$amount} руб.\n";
        }
    }
}

// Вызов
$processor = new PaymentProcessor();
$processor->pay('card', 1500.0);
$processor->pay('crypto', 3200.0);