← Назад к вопросам

Возможно ли полностью реализовать принцип SOLID Барбары Лисков?

1.7 Middle🔥 171 комментариев
#JavaScript Core

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI2 апр. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Возможно ли полностью реализовать принцип Лисков (LSP)?

Короткий ответ: Сложно полностью реализовать в реальных проектах, но это стоит того. Принцип Лисков — один из самых сложных принципов SOLID, но и один из самых важных.

Что такое Принцип подстановки Лисков (LSP)?

Liskov Substitution Principle (LSP) — это принцип, сформулированный Барбарой Лисков. Он гласит:

"Объекты в программе должны быть заменяемы на экземпляры их подтипов без изменения желаемых свойств этой программы"

Проще говоря: если класс B является подклассом класса A, то мы должны иметь возможность заменить A на B, не нарушая логику программы.

Пример нарушения LSP

Классический пример: квадрат и прямоугольник

// ПЛОХО: нарушение LSP
class Rectangle {
  constructor(width, height) {
    this.width = width;
    this.height = height;
  }
  
  setWidth(w) { this.width = w; }
  setHeight(h) { this.height = h; }
  
  getArea() { return this.width * this.height; }
}

class Square extends Rectangle {
  // Квадрат ДОЛЖЕН иметь равные стороны
  setWidth(w) { 
    this.width = w;
    this.height = w; // Принудительно делаем обе стороны равными
  }
  
  setHeight(h) { 
    this.width = h;
    this.height = h;
  }
}

// Использование
function testArea(shape) {
  shape.setWidth(5);
  shape.setHeight(3);
  console.log(shape.getArea()); // Ожидаем 15
}

const rect = new Rectangle(0, 0);
testArea(rect); // 15 - правильно

const square = new Square(0, 0);
testArea(square); // 9 - НЕПРАВИЛЬНО! Квадрат "нарушил" контракт

Проблема: Square нарушает контракт Rectangle, потому что нельзя независимо устанавливать ширину и высоту.

Как исправить нарушение LSP?

Решение 1: Правильная иерархия классов

// Правильная иерархия
class Shape {
  getArea() {
    throw new Error('Must be implemented');
  }
}

class Rectangle extends Shape {
  constructor(width, height) {
    super();
    this.width = width;
    this.height = height;
  }
  
  getArea() { return this.width * this.height; }
}

class Square extends Shape {
  constructor(side) {
    super();
    this.side = side;
  }
  
  getArea() { return this.side * this.side; }
}

// Теперь работает правильно
function testArea(shape) {
  console.log(shape.getArea());
}

testArea(new Rectangle(5, 3)); // 15
testArea(new Square(4)); // 16

LSP в React и TypeScript

Пример: Компоненты кнопок

// ПЛОХО: нарушение LSP
interface ButtonProps {
  onClick: () => void;
  children: React.ReactNode;
}

const Button: React.FC<ButtonProps> = ({ onClick, children }) => (
  <button onClick={onClick}>{children}</button>
);

const IconButton: React.FC<ButtonProps & { icon: string }> = ({ 
  onClick, 
  children, 
  icon 
}) => {
  // ПРОБЛЕМА: IconButton требует 'icon' пропс
  // Но он не является подтипом Button, потому что требует доп. параметр
  return (
    <button onClick={onClick}>
      <img src={icon} alt="" /> {children}
    </button>
  );
};

// Использование нарушает LSP
function renderButton(Button: React.FC<ButtonProps>) {
  return <Button onClick={() => {}} children="Click me" />;
}

renderButton(Button); // OK
renderButton(IconButton); // ОШИБКА: missing 'icon'

Правильное решение

// Правильно: оба компонента удовлетворяют контракту ButtonProps
interface ButtonProps {
  onClick: () => void;
  children: React.ReactNode;
}

const Button: React.FC<ButtonProps> = ({ onClick, children }) => (
  <button onClick={onClick}>{children}</button>
);

const IconButton: React.FC<ButtonProps & { icon?: string }> = ({ 
  onClick, 
  children, 
  icon 
}) => (
  <button onClick={onClick}>
    {icon && <img src={icon} alt="" />}
    {children}
  </button>
);

// Теперь оба работают как подтипы Button
function renderButton(ButtonComponent: React.FC<ButtonProps>) {
  return <ButtonComponent onClick={() => {}} children="Click me" />;
}

renderButton(Button); // OK
renderButton(IconButton); // OK (icon опциональный)

LSP в API / сервисах

Пример: API клиент

// Интерфейс (контракт)
interface APIClient {
  get<T>(url: string): Promise<T>;
  post<T>(url: string, data: any): Promise<T>;
  put<T>(url: string, data: any): Promise<T>;
  delete(url: string): Promise<void>;
}

// Реализация с HTTP клиентом
class HttpClient implements APIClient {
  async get<T>(url: string): Promise<T> {
    const res = await fetch(url);
    return res.json();
  }
  
  async post<T>(url: string, data: any): Promise<T> {
    const res = await fetch(url, {
      method: 'POST',
      body: JSON.stringify(data),
    });
    return res.json();
  }
  
  async put<T>(url: string, data: any): Promise<T> {
    const res = await fetch(url, {
      method: 'PUT',
      body: JSON.stringify(data),
    });
    return res.json();
  }
  
  async delete(url: string): Promise<void> {
    await fetch(url, { method: 'DELETE' });
  }
}

// Реализация с кешем
class CachedApiClient implements APIClient {
  private cache = new Map<string, any>();
  
  async get<T>(url: string): Promise<T> {
    if (this.cache.has(url)) {
      return this.cache.get(url);
    }
    const res = await fetch(url);
    const data = await res.json();
    this.cache.set(url, data);
    return data;
  }
  
  // ... остальные методы
  async post<T>(url: string, data: any): Promise<T> {
    this.cache.clear(); // Инвалидируем кеш при изменении
    return fetch(url, { method: 'POST', body: JSON.stringify(data) })
      .then(r => r.json());
  }
  
  async put<T>(url: string, data: any): Promise<T> {
    this.cache.clear();
    return fetch(url, { method: 'PUT', body: JSON.stringify(data) })
      .then(r => r.json());
  }
  
  async delete(url: string): Promise<void> {
    this.cache.clear();
    await fetch(url, { method: 'DELETE' });
  }
}

// Использование: обе реализации совместимы
function fetchUserData(client: APIClient) {
  return client.get<User>('/api/users/123');
}

fetchUserData(new HttpClient());       // OK
fetchUserData(new CachedApiClient()); // OK - оба работают

Почему LSP сложно полностью реализовать?

1. Неявные контракты

// Контракт может быть не явным в коде, а в документации
interface UserService {
  getUser(id: string): Promise<User>;
  // ПОДРАЗУМЕВАЕТСЯ: user должен существовать, иначе 404
  // ПОДРАЗУМЕВАЕТСЯ: результат кешируется
  // ПОДРАЗУМЕВАЕТСЯ: никогда не выбросит исключение (кроме 404)
}

// Реализация может нарушить неявные контракты
class SlowUserService implements UserService {
  async getUser(id: string): Promise<User> {
    // Работает медленнее, но это нарушает неявный контракт? Может быть...
    await new Promise(r => setTimeout(r, 5000));
    return fetch(`/api/users/${id}`).then(r => r.json());
  }
}

2. Предусловия и постусловия

// LSP говорит: подтип не может иметь БОЛЕЕ СТРОГИЕ предусловия
// или МЕНЕЕ СТРОГИЕ постусловия

class PaymentProcessor {
  // Предусловие: amount > 0
  // Постусловие: возвращает success или выбрасывает ошибку
  processPayment(amount: number): boolean {
    if (amount <= 0) throw new Error('Invalid amount');
    // Логика обработки...
    return true;
  }
}

// ПЛОХО: подтип имеет БОЛЕЕ СТРОГИЕ предусловия
class StrictPaymentProcessor extends PaymentProcessor {
  processPayment(amount: number): boolean {
    // БОЛЕЕ СТРОГОЕ предусловие: amount >= 100
    if (amount < 100) throw new Error('Minimum 100');
    return super.processPayment(amount);
  }
  // Клиент, ожидавший работать с amount = 50, теперь получит ошибку
  // LSP нарушен!
}

Как применять LSP в реальных проектах?

1. Тестирование подтипов

// Тесты должны убедить, что подтип работает как базовый тип
test('CachedApiClient должен работать как HttpClient', async () => {
  // Тестируем, что CachedApiClient удовлетворяет контракту APIClient
  const client = new CachedApiClient();
  
  const user = await client.get<User>('/api/users/123');
  expect(user).toHaveProperty('id');
  expect(user).toHaveProperty('name');
  // Не должны быть никакие новые требования
});

// Лучше: использовать parameterized тесты
function testAPIClient(ClientClass: typeof HttpClient | typeof CachedApiClient) {
  test(`${ClientClass.name} соответствует контракту APIClient`, async () => {
    const client = new ClientClass();
    const user = await client.get<User>('/api/users/123');
    expect(user).toBeDefined();
  });
}

testAPIClient(HttpClient);
testAPIClient(CachedApiClient);

2. Явные контракты (документация)

/**
 * Интерфейс для получения данных пользователя
 * 
 * Контракт:
 * - getUser(id) никогда не кешируется
 * - getUser(id) выбрасывает NotFoundException если пользователь не найден
 * - getUser(id) возвращает User с полностью заполненными полями
 * - getUser(id) выполняется менее чем за 500ms
 * 
 * Подтипы ДОЛЖНЫ соблюдать этот контракт
 */
interface UserRepository {
  getUser(id: string): Promise<User>;
}

3. Использование composition вместо inheritance

// Вместо наследования, используй композицию
// Это избегает многих проблем LSP

class CachedUserRepository {
  constructor(private repository: UserRepository) {}
  
  async getUser(id: string): Promise<User> {
    // Кешируем, но с явным API
    return this.repository.getUser(id);
  }
}

// Вместо того, чтобы расширять UserRepository
// Мы создаём новый класс, который явно использует repository

Возможно ли полностью реализовать LSP?

Реальность:

  1. 100% реализация практически невозможна — слишком много неявных предположений
  2. Но 80% реализация реалистична — если быть внимательным к контрактам
  3. Выигрыш стоит усилий — код становится более гибким и тестируемым
  4. Используй TypeScript — типизация помогает выявлять нарушения LSP
  5. Тестируй подтипы — убедись, что подтипы работают как базовые типы
  6. Документируй контракты — явные требования помогают разработчикам
  7. Предпочитай композицию наследованию — избегает многих проблем

Практические правила

// Правило 1: Если подтип требует доп. параметры
// -> Это нарушение LSP

// Правило 2: Если подтип имеет более строгие требования
// -> Это нарушение LSP

// Правило 3: Если подтип возвращает менее полный результат
// -> Это нарушение LSP

// Правило 4: Если подтип может выбросить неожиданное исключение
// -> Это нарушение LSP

Заключение

Принцип Лисков — это про замещаемость. Полностью реализовать его на 100% в реальных проектах сложно из-за:

  • Неявных контрактов
  • Сложности программных систем
  • Новых требований, появляющихся со временем

Но努力 на 70-80% реализации:

  1. Делает код более гибким
  2. Упрощает тестирование
  3. Снижает связанность
  4. Облегчает добавление новых типов

Сосредоточься на явных контрактах, тестировании подтипов и использовании композиции — и ты будешь близко к идеалу LSP.