Программа курса← Объектно ориентированное программирование
1История ООП
2Объект и Класс
3Базовые концепции ООП
4Принцип проектирования GRASP
5Принцип проектирования SOLID
6Паттерны GOF
7Методические материалы

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

Класс должен иметь только одну причину для изменения. Иными словами: один класс – одна обязанность.

Если объект делает слишком много, его сложнее поддерживать и изменять, а правка в одной части может сломать другую.

Причина для изменения, а не количество методов

Формулировку часто понимают как «класс должен быть маленьким». Это не так: важна не длина, а источник изменений. Спросите себя, кто может прийти с требованием поменять этот класс. Если таких заказчиков двое – бухгалтерия и отдел вёрстки писем, – обязанностей тоже две.

Как выглядит нарушение

Классический пример самого Мартина – класс сотрудника, у которого три заказчика:

// Бухгалтерия, отдел кадров и администратор базы – три источника правок
public class Employee
{
    public decimal CalculatePay() => RegularHours() * _rate;        // просит бухгалтерия
    public string ReportHours() => $"{RegularHours():0.0} ч";       // просит отдел кадров
    public void Save() => Db.Execute("UPDATE employees ...", this); // просит администратор

    // Общий кусок, из-за которого всё и ломается
    private decimal RegularHours() => _shifts.Sum(s => Math.Min(s.Hours, 8));

    private readonly decimal _rate;
    private readonly List<Shift> _shifts = new();
}
// Бухгалтерия, отдел кадров и администратор базы – три источника правок
class Employee {
public:
    double CalculatePay() const { return RegularHours() * rate_; }   // просит бухгалтерия
    std::string ReportHours() const { return Format(RegularHours()); } // просит отдел кадров
    void Save() const { Db().Execute("UPDATE employees ...", *this); } // просит администратор

private:
    // Общий кусок, из-за которого всё и ломается
    double RegularHours() const {
        double total = 0;
        for (const auto& shift : shifts_) total += std::min(shift.hours, 8.0);

        return total;
    }

    double rate_ = 0;
    std::vector<Shift> shifts_;
};
// Бухгалтерия, отдел кадров и администратор базы – три источника правок
type Employee struct {
	rate   float64
	shifts []Shift
}

func (e Employee) CalculatePay() float64 { return e.regularHours() * e.rate } // бухгалтерия

func (e Employee) ReportHours() string { // отдел кадров
	return fmt.Sprintf("%.1f ч", e.regularHours())
}

func (e Employee) Save() error { // администратор базы
	return db.Exec("UPDATE employees ...", e)
}

// Общий кусок, из-за которого всё и ломается
func (e Employee) regularHours() float64 {
	total := 0.0
	for _, shift := range e.shifts {
		total += math.Min(shift.Hours, 8)
	}

	return total
}
# Бухгалтерия, отдел кадров и администратор базы – три источника правок
class Employee:
    def calculate_pay(self) -> float:  # просит бухгалтерия
        return self._regular_hours() * self._rate

    def report_hours(self) -> str:  # просит отдел кадров
        return f"{self._regular_hours():.1f} ч"

    def save(self) -> None:  # просит администратор базы
        db.execute("UPDATE employees ...", self)

    # Общий кусок, из-за которого всё и ломается
    def _regular_hours(self) -> float:
        return sum(min(shift.hours, 8) for shift in self._shifts)
// Бухгалтерия, отдел кадров и администратор базы – три источника правок
class Employee {
    calculatePay(): number {
        return this.regularHours() * this.rate; // просит бухгалтерия
    }

    reportHours(): string {
        return `${this.regularHours().toFixed(1)} ч`; // просит отдел кадров
    }

    save(): void {
        db.execute('UPDATE employees ...', this); // просит администратор базы
    }

    // Общий кусок, из-за которого всё и ломается
    private regularHours(): number {
        return this.shifts.reduce((sum, shift) => sum + Math.min(shift.hours, 8), 0);
    }
}
// Бухгалтерия, отдел кадров и администратор базы – три источника правок
public class Employee {
    public BigDecimal calculatePay() { // просит бухгалтерия
        return regularHours().multiply(rate);
    }

    public String reportHours() { // просит отдел кадров
        return regularHours() + " ч";
    }

    public void save() { // просит администратор базы
        db.execute("UPDATE employees ...", this);
    }

    // Общий кусок, из-за которого всё и ломается
    private BigDecimal regularHours() {
        return shifts.stream()
                .map(s -> s.getHours().min(BigDecimal.valueOf(8)))
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}
// Бухгалтерия, отдел кадров и администратор базы – три источника правок
class Employee(private val rate: Double, private val shifts: List<Shift>) {
    fun calculatePay() = regularHours() * rate // просит бухгалтерия

    fun reportHours() = "%.1f ч".format(regularHours()) // просит отдел кадров

    fun save() = db.execute("UPDATE employees ...", this) // просит администратор базы

    // Общий кусок, из-за которого всё и ломается
    private fun regularHours() = shifts.sumOf { minOf(it.hours, 8.0) }
}

Методов всего три, класс маленький – и всё равно это нарушение. Смотреть надо не на размер, а на то, кто приходит с правками:

  • три заказчика на один файл – бухгалтерия меняет правила оплаты, кадры – форму отчёта, администратор базы – схему таблицы; каждый со своим сроком и своими причинами;
  • правка одного тихо ломает другое – кадры просят не засчитывать в отчёт больше восьми часов, кто-то правит RegularHours(), и вместе с отчётом меняется зарплата. Ошибку заметят в день выплаты;
  • тест ни о чём не говорит – класс падает по трём независимым причинам, и красный тест не подсказывает, какая сработала;
  • правки сталкиваются – три команды коммитят в один файл и разбирают конфликты.

Как разделить

Данные остаются у сотрудника, а каждая обязанность уезжает к своему заказчику:

// Данные – в одном классе, три обязанности – в трёх
public class Employee
{
    public decimal Rate { get; init; }
    public IReadOnlyList<Shift> Shifts { get; init; } = [];
}

public class PayCalculator // бухгалтерия
{
    public decimal Calculate(Employee employee) =>
        employee.Shifts.Sum(s => Math.Min(s.Hours, 8)) * employee.Rate;
}

public class HourReporter // отдел кадров
{
    public string Report(Employee employee) =>
        $"{employee.Shifts.Sum(s => s.Hours):0.0} ч";
}

public class EmployeeRepository // администратор базы
{
    public void Save(Employee employee) => Db.Execute("UPDATE employees ...", employee);
}
// Данные – в одном классе, три обязанности – в трёх
struct Employee {
    double rate = 0;
    std::vector<Shift> shifts;
};

class PayCalculator {  // бухгалтерия
public:
    double Calculate(const Employee& employee) const;
};

class HourReporter {  // отдел кадров
public:
    std::string Report(const Employee& employee) const;
};

class EmployeeRepository {  // администратор базы
public:
    void Save(const Employee& employee) const {
        Db().Execute("UPDATE employees ...", employee);
    }
};
// Данные – в одном типе, три обязанности – в трёх
type Employee struct {
	Rate   float64
	Shifts []Shift
}

type PayCalculator struct{} // бухгалтерия

func (PayCalculator) Calculate(e Employee) float64 {
	total := 0.0
	for _, shift := range e.Shifts {
		total += math.Min(shift.Hours, 8)
	}

	return total * e.Rate
}

type HourReporter struct{} // отдел кадров

func (HourReporter) Report(e Employee) string {
	total := 0.0
	for _, shift := range e.Shifts {
		total += shift.Hours
	}

	return fmt.Sprintf("%.1f ч", total)
}

type EmployeeRepository struct{} // администратор базы

func (EmployeeRepository) Save(e Employee) error { return db.Exec("UPDATE employees ...", e) }
# Данные – в одном классе, три обязанности – в трёх
@dataclass
class Employee:
    rate: float
    shifts: list[Shift]

class PayCalculator:  # бухгалтерия
    def calculate(self, employee: Employee) -> float:
        return sum(min(s.hours, 8) for s in employee.shifts) * employee.rate

class HourReporter:  # отдел кадров
    def report(self, employee: Employee) -> str:
        return f"{sum(s.hours for s in employee.shifts):.1f} ч"

class EmployeeRepository:  # администратор базы
    def save(self, employee: Employee) -> None:
        db.execute("UPDATE employees ...", employee)
// Данные – в одном классе, три обязанности – в трёх
interface Employee {
    rate: number;
    shifts: Shift[];
}

class PayCalculator {
    calculate(employee: Employee): number {
        return employee.shifts.reduce((sum, s) => sum + Math.min(s.hours, 8), 0) * employee.rate;
    }
}

class HourReporter {
    report(employee: Employee): string {
        return `${employee.shifts.reduce((sum, s) => sum + s.hours, 0).toFixed(1)} ч`;
    }
}

class EmployeeRepository {
    save(employee: Employee): void {
        db.execute('UPDATE employees ...', employee);
    }
}
// Данные – в одном классе, три обязанности – в трёх
public record Employee(BigDecimal rate, List<Shift> shifts) { }

public class PayCalculator { // бухгалтерия
    public BigDecimal calculate(Employee employee) { ... }
}

public class HourReporter { // отдел кадров
    public String report(Employee employee) { ... }
}

public class EmployeeRepository { // администратор базы
    public void save(Employee employee) {
        db.execute("UPDATE employees ...", employee);
    }
}
// Данные – в одном классе, три обязанности – в трёх
data class Employee(val rate: Double, val shifts: List<Shift>)

class PayCalculator { // бухгалтерия
    fun calculate(employee: Employee) =
        employee.shifts.sumOf { minOf(it.hours, 8.0) } * employee.rate
}

class HourReporter { // отдел кадров
    fun report(employee: Employee) = "%.1f ч".format(employee.shifts.sumOf { it.hours })
}

class EmployeeRepository { // администратор базы
    fun save(employee: Employee) = db.execute("UPDATE employees ...", employee)
}

Обратите внимание на главное: расчёт часов теперь считается дважды и по-разному. Это не дублирование, которое надо выносить в общий метод, – это два разных правила, которые раньше случайно совпадали. Пока они жили в одном методе, любая правка одного меняла второе.

Что даёт соблюдение

  • Изменение требования затрагивает один файл. Поменялись правила начисления зарплаты – правится расчёт, и только он; формат отчёта об этом не знает.
  • Тест становится осмысленным. У класса одна причина упасть, поэтому по красному тесту сразу понятно, что именно сломалось.
  • Меньше конфликтов при слиянии. Бухгалтерия и отдел отчётности правят разные файлы, а не соседние строки одного класса.
  • Класс легко назвать. Если имя подбирается без союза «и» – ответственность одна. Это самая дешёвая проверка принципа, и работает она ещё до написания кода.

Чем платят

Классов становится больше, и по одному сценарию приходится ходить по нескольким файлам. Принцип окупается там, где код действительно меняется; дробить стабильный класс ради самого дробления смысла нет.