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`);
}
}
Контракт интерфейса = 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-а с переопределённым методом и оцени, не нарушает ли он ожидания базового типа