L - Liskov Substitution: подтипы без сюрпризов

LSP: объекты подкласса должны заменять объекты базового класса без поломки логики.

Если ты подставил наследника и всё сломалось - это нарушение LSP.

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

«Квадрат - это частный случай прямоугольника» - звучит математически безупречно. На бумаге Square идеально наследует Rectangle. На практике LSP ломается:

type Rectangle struct{ Width, Height int }
func (r *Rectangle) SetWidth(w int)  { r.Width = w }
func (r *Rectangle) SetHeight(h int) { r.Height = h }
func (r *Rectangle) Area() int       { return r.Width * r.Height }

type Square struct{ Rectangle } // встроили - теперь Square имеет SetWidth/SetHeight

// Чтобы сохранить инвариант «у квадрата стороны равны», переопределяем:
func (s *Square) SetWidth(w int)  { s.Width = w; s.Height = w }
func (s *Square) SetHeight(h int) { s.Width = h; s.Height = h }
<?php
declare(strict_types=1);

class Rectangle
{
    public function __construct(protected int $width, protected int $height) {}

    public function setWidth(int $w): void  { $this->width = $w; }
    public function setHeight(int $h): void { $this->height = $h; }
    public function area(): int             { return $this->width * $this->height; }
}

final class Square extends Rectangle
{
    // Чтобы сохранить инвариант «у квадрата стороны равны», переопределяем:
    public function setWidth(int $w): void  { $this->width = $w; $this->height = $w; }
    public function setHeight(int $h): void { $this->width = $h; $this->height = $h; }
}
class Rectangle {
  constructor(width, height) {
    this.width = width;
    this.height = height;
  }
  setWidth(w)  { this.width = w; }
  setHeight(h) { this.height = h; }
  area()       { 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; }
}

А теперь функция, которая написана для Rectangle и не знает про Square:

func GrowToFit(r *Rectangle, w, h int) int {
    r.SetWidth(w)
    r.SetHeight(h)
    return r.Area() // ожидаем w*h
}
<?php
declare(strict_types=1);

function growToFit(Rectangle $r, int $w, int $h): int
{
    $r->setWidth($w);
    $r->setHeight($h);
    return $r->area(); // ожидаем w*h
}
/**
 * @param {Rectangle} r
 * @param {number} w
 * @param {number} h
 * @returns {number}
 */
function growToFit(r, w, h) {
  r.setWidth(w);
  r.setHeight(h);
  return r.area(); // ожидаем w*h
}

Подставляем Square вместо Rectangle - получаем h*h вместо w*h. Код, корректный для базового типа, ломается на подтипе. Это и есть нарушение LSP: подтип не сохранил наблюдаемое поведение базы.

Урок: наследование «is-a» в реальном мире и наследование контракта в коде - не одно и то же. В Go это особенно остро через embedding.

Практичный пример: «репозиторий» без сюрпризов

type UserRepo interface {
  Get(id int64) (User, error)
}

type PgUserRepo struct{}
func (PgUserRepo) Get(id int64) (User, error) { /* ok */ return User{}, nil }

type FakeUserRepo struct{}
func (FakeUserRepo) Get(id int64) (User, error) {
  // Должно вести себя так же: либо User, либо error.
  return User{}, sql.ErrNoRows
}
interface UserRepo
{
    public function get(int $id): array;
}

final class PgUserRepo implements UserRepo
{
    public function get(int $id): array
    {
        return ['id' => $id];
    }
}

final class FakeUserRepo implements UserRepo
{
    public function get(int $id): array
    {
        // Если контракт: либо массив пользователя, либо исключение - придерживаемся.
        throw new RuntimeException('not found');
    }
}
// Контракт UserRepo: get() вернёт User или бросит NotFoundError.
// Любая реализация обязана следовать этому правилу.
/** @typedef {{ get: (id: number) => Promise<User> }} UserRepo */

class NotFoundError extends Error {}

class PgUserRepo {
  async get(id) {
    // ok - возвращает User
    return { id };
  }
}

class FakeUserRepo {
  async get(id) {
    // Сохраняем контракт: либо User, либо NotFoundError - не panic, не null
    throw new NotFoundError(`user ${id} not found`);
  }
}
Если `FakeUserRepo.Get()` вдруг начнёт паниковать вместо ошибки/исключения - код, ожидающий стандартное поведение, развалится.

Контракт интерфейса = preconditions + postconditions + инварианты

LSP формализуется через design-by-contract:

  • preconditions - что обязан гарантировать вызывающий код перед вызовом метода (валидный id, ненулевой контекст)
  • postconditions - что обязан гарантировать метод после успешного выполнения (вернёт User или error, не оставит мусор в БД)
  • инварианты - что справедливо для типа в целом (UserRepo не должен паниковать)

Правило Барбары Лисков: подтип может ослабить preconditions (принимать больше) и усилить postconditions (возвращать конкретнее), но никогда наоборот. Если фейк начинает требовать заполненный RequestID в контексте, а Postgres-реализация - нет, фейк ужесточил precondition и нарушил LSP.

LSP в Go: интерфейсы - это контракты

В Go нет наследования классов, и иногда возникает соблазн думать, что LSP здесь не нужен. Это заблуждение. LSP - это про подтипирование, а не про конкретный механизм наследования. В Go механизм подтипирования один - интерфейсы и их реализации:

type UserRepo interface { // базовый «тип» (контракт)
    Get(ctx context.Context, id int64) (User, error)
}

// Каждая реализация - это «подтип» в смысле LSP
type PgUserRepo struct{ db *sql.DB }       // реализация-подтип
type RedisUserRepo struct{ rdb *redis.Client } // ещё один подтип
type FakeUserRepo struct{ users map[int64]User } // и ещё один
<?php
declare(strict_types=1);

interface UserRepo // базовый «тип» (контракт)
{
    public function get(int $id): User;
}

// Каждая реализация - это «подтип» в смысле LSP
final class PdoUserRepo implements UserRepo
{
    public function __construct(private readonly \PDO $db) {}
    public function get(int $id): User { /* ... */ throw new \LogicException(); }
}

final class RedisUserRepo implements UserRepo
{
    public function __construct(private readonly \Redis $redis) {}
    public function get(int $id): User { /* ... */ throw new \LogicException(); }
}

final class FakeUserRepo implements UserRepo
{
    /** @var array<int, User> */
    private array $users = [];
    public function get(int $id): User { /* ... */ throw new \LogicException(); }
}
// JavaScript использует «duck typing» - подтип = совпадение по форме.
/** @typedef {{ get: (id: number) => Promise<User> }} UserRepo */

class PgUserRepo {
  #db;
  constructor(db) { this.#db = db; }
  async get(id) { /* ... */ }
}

class RedisUserRepo {
  #redis;
  constructor(redis) { this.#redis = redis; }
  async get(id) { /* ... */ }
}

class FakeUserRepo {
  #users = new Map();
  async get(id) { /* ... */ }
}

Любой код, написанный против UserRepo, должен корректно работать с любой из этих реализаций. Это и есть LSP, переведённый на язык Go: подтип = реализация интерфейса, контракт = интерфейс.

Поэтому LSP в Go проявляется везде, где есть полиморфизм через интерфейсы - а это практически весь идиоматический Go-код. Если фейк ведёт себя иначе, чем Postgres-реализация, клиентский код, написанный на тестах с фейком, рассыплется в проде. Это и есть нарушение принципа подстановки.

Embedding gotchas в Go

Go не имеет наследования, но embedding создаёт похожую иллюзию - и те же ловушки:

type Base struct{}
func (Base) Save(ctx context.Context) error { return nil }

type Cached struct {
    Base
    cache map[string]string
}
// Если переопределить Save и забыть про инвариант базового типа -
// код, который ожидал поведение Base, сломается на Cached.
func (c *Cached) Save(ctx context.Context) error {
    c.cache = nil // делаем что-то ещё
    return nil
}
<?php
declare(strict_types=1);

// В PHP та же ловушка возникает при extends + переопределении метода:
// если потомок ломает постусловие родителя, клиентский код, ожидающий
// поведение Base, развалится на Cached.

class Base
{
    public function save(): void { /* persist */ }
}

final class Cached extends Base
{
    /** @var array<string, string> */
    private array $cache = [];

    public function save(): void
    {
        $this->cache = []; // делаем что-то ещё
        // забыли вызвать parent::save() - данные не сохранятся
    }
}
class Base {
  async save() { /* persist */ }
}

class Cached extends Base {
  cache = new Map();

  async save() {
    this.cache.clear(); // делаем что-то ещё
    // забыли await super.save() - данные не сохранятся, контракт Base нарушен
  }
}

Это меньшая проблема, чем классическое наследование, потому что Go-код обычно работает с интерфейсами, а не с конкретными типами. Но как только клиент начинает делать type assertion (if cached, ok := r.(*Cached); ok) - иерархия даёт о себе знать.

Как проверить LSP

  • прочитай контракт интерфейса (включая «как я веду себя при ошибке»)
  • подставь каждую реализацию в уме: «а сломает ли это ожидающее поведение?»
  • лучше - напиши общий тестовый пакет, прогоняющий один и тот же набор сценариев на всех реализациях
// Один тест на все реализации UserRepo - гарантия LSP по построению
func RunUserRepoContract(t *testing.T, makeRepo func() UserRepo) {
    t.Run("Get несуществующего возвращает sql.ErrNoRows", func(t *testing.T) {
        repo := makeRepo()
        _, err := repo.Get(context.Background(), -1)
        require.ErrorIs(t, err, sql.ErrNoRows)
    })
    // ... другие инварианты контракта
}

func TestPgUserRepo(t *testing.T)   { RunUserRepoContract(t, func() UserRepo { return NewPgRepo(testDB) }) }
func TestFakeUserRepo(t *testing.T) { RunUserRepoContract(t, func() UserRepo { return NewFakeRepo() }) }
<?php
declare(strict_types=1);

// Один абстрактный тест на все реализации UserRepo - гарантия LSP по построению.
// В PHPUnit это базовый abstract класс с реальными тестами + потомки,
// возвращающие конкретную реализацию.

namespace App\Tests\User;

use App\User\UserNotFoundException;
use App\User\UserRepo;
use PHPUnit\Framework\TestCase;

abstract class UserRepoContractTest extends TestCase
{
    abstract protected function makeRepo(): UserRepo;

    public function testGetMissingThrowsUserNotFound(): void
    {
        $this->expectException(UserNotFoundException::class);
        $this->makeRepo()->get(-1);
    }
    // ... другие инварианты контракта
}

final class PdoUserRepoTest extends UserRepoContractTest
{
    protected function makeRepo(): UserRepo { return new PdoUserRepo(self::testPdo()); }
}

final class FakeUserRepoTest extends UserRepoContractTest
{
    protected function makeRepo(): UserRepo { return new FakeUserRepo(); }
}
// Один общий набор тестов для всех реализаций UserRepo (Vitest / Jest).
import { describe, it, expect } from 'vitest';

class NotFoundError extends Error {}

/**
 * @param {() => UserRepo} makeRepo
 */
function runUserRepoContract(makeRepo) {
  it('Get несуществующего бросает NotFoundError', async () => {
    const repo = makeRepo();
    await expect(repo.get(-1)).rejects.toBeInstanceOf(NotFoundError);
  });
  // ... другие инварианты контракта
}

describe('PgUserRepo', () => runUserRepoContract(() => new PgUserRepo(testDb)));
describe('FakeUserRepo', () => runUserRepoContract(() => new FakeUserRepo()));

Если фейк перестаёт проходить контрактный тест, значит его клиенты тоже сломаются в реальной интеграции. Это самый дешёвый способ ловить LSP-нарушения автоматически.

Где LSP проявляется в реальном Go-коде

Самые частые места, где нарушается LSP в живых проектах:

  • Фейковые репозитории, которые паникуют там, где Postgres-реализация возвращает ошибку
  • Декорированные клиенты (например, кеширующая обёртка), которые молча проглатывают ошибки сети, считая «нет данных в кеше = нет данных вообще»
  • Тестовые HTTP-клиенты, не соблюдающие тайм-ауты, которые гарантирует прод-реализация
  • Адаптеры для разных БД, по-разному ведущие себя при race conditions (один блокирует, другой возвращает специфическую ошибку)
  • «Гибкие» реализации, которые принимают nil там, где базовый контракт требовал ненулевой указатель
  • Кеширующие декораторы, у которых TTL ломает гарантии свежести данных, заявленные в основном интерфейсе
  • In-memory имплементации, которые игнорируют контекст и не отменяют операции по ctx.Done()
  • Метричные обёртки, которые добавляют побочные эффекты (записи в Prometheus) и блокируются, если метрик-сервер недоступен

В каждом случае подтип формально удовлетворяет интерфейсу (методы те же), но наблюдаемое поведение отличается - и это отличие проявляется только под нагрузкой или на граничных случаях.

Мини‑задание

  • Проверь свои реализации интерфейсов: одинаково ли они ведут себя при ошибках?
  • Заведи общий контрактный тест для одного интерфейса с двумя+ реализациями - все ли они его проходят?
  • Найди случай embedding-а с переопределённым методом и оцени, не нарушает ли он ожидания базового типа

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.