JavaRush /Курсы /C++ SELF /Контракт интерфейса

Контракт интерфейса

C++ SELF
52 уровень , 1 лекция
Открыта

1. Зачем интерфейсу нужен контракт

Если вы когда-нибудь брали в руки чужой код и думали «а что эта функция вообще обещает?», то вы уже сталкивались с проблемой контракта. Интерфейс без контракта — как кнопка без подписи: нажимать страшно, потому что непонятно, это «Сохранить» или «Удалить всё и послать письмо начальнику».

Контракт интерфейса — это не академическая формальность. Это способ сделать так, чтобы разные реализации интерфейса вели себя одинаково «в смысле пользователя». Без контракта вы получите ситуацию: одна реализация возвращает пустую строку при ошибке, другая возвращает nullopt, третья печатает сообщение в консоль (да, так тоже делают…), а четвёртая «молча ничего не делает». И в этот момент ваш код превращается в сериал с непредсказуемым сюжетом.

Интересный момент: даже в документах по стандарту C++ формулировки «postconditions» встречаются как нормальная часть спецификации, потому что библиотека тоже должна выполнять договор.

Предусловия и постусловия: кто кому что должен

Когда мы говорим «контракт», почти всегда удобно мыслить в двух половинках: предусловия и постусловия. В этом месте многие новички думают: «О, это как “до” и “после”». Да — и это прекрасная аналогия, пока мы не начинаем спорить, кто должен мыть посуду.

Предусловие — это то, что обязан обеспечить вызывающий код перед вызовом метода. То есть это требования к аргументам и/или состоянию объекта. Например: «ключ не должен быть пустым», «индекс должен быть в диапазоне», «указатель может быть nullptr — и это отдельный сценарий».

Постусловие — это то, что обязана гарантировать реализация метода после завершения. Например: «если метод вернул true, то элемент действительно удалён», «если вернулся std::optional со значением, то оно соответствует найденной записи», «состояние объекта остаётся валидным».

Чтобы стало совсем приземлённо, вот маленькая «таблица договорённости» (не список, а прям компактная шпаргалка):

Термин Вопрос человеческим языком Кто отвечает
Предусловие «С чем мне можно вызывать метод, чтобы было корректно?» Вызывающий код
Постусловие «Что метод гарантирует, если я вызвал его корректно?» Реализация

Самое важное: предусловия и постусловия должны быть едиными для всех реализаций интерфейса. Иначе интерфейс перестаёт быть интерфейсом, он становится «абстрактной загадкой».

2. Почему интерфейс должен быть тонким

Есть соблазн сделать «богатый интерфейс», который умеет всё. В реальности такой интерфейс часто превращается в швейцарский нож… только половина инструментов у него — декоративные, а штопор внезапно удаляет файлы.

Когда методы маленькие, проще понимать контракт, тестировать, реализовывать, поддерживать несколько реализаций и расширять программу без переписывания всего мира.

А теперь важная мысль: «маленький метод» — это не обязательно «метод с одной строчкой внутри». Это метод, который делает одну понятную операцию. Даже если внутри 20 строк — это может быть нормально, если операция одна и смысл ясен.

Мини-антипример: интерфейс «сделай мне хорошо»

Начнём с плохого примера. Он плох не потому, что компилятор ругается. Он плох потому, что его невозможно нормально использовать, не читая реализацию.

#include <string>

struct IStorageBad {
    virtual ~IStorageBad() = default;
    virtual std::string do_everything(const std::string& input) = 0;
};

Контракт тут звучит примерно так: «передай строку — получишь… что-то». А если input пустой? А если там пробелы? А если ключа нет? А если ошибка? Возвращаем пустую строку? Строку "ERROR"? Пишем в std::cout? Паника.

Улучшаем: интерфейс из маленьких операций

Мы хотим, чтобы каждое действие было отдельным, а результат/неуспех выражались типом.

#include <optional>
#include <string>
#include <string_view>

struct IKeyValueStore {
    virtual ~IKeyValueStore() = default;

    // Предусловие: key не пустой.
    // Постусловие: если ключ существует — вернёт значение, иначе nullopt.
    virtual std::optional<std::string> get(std::string_view key) const = 0;

    // Предусловие: key не пустой.
    // Постусловие: после set(key, v) get(key) вернёт v.
    virtual void set(std::string_view key, std::string_view value) = 0;
};

Методов больше, но интерфейс стал в разы понятнее. И самое главное — контракт читается глазами, без телепатии.

4. Контракт в сигнатуре: типы, const, string_view, optional

Очень хочется думать, что контракт — это только комментарии. Но на практике лучший контракт — тот, который компилятор помогает соблюдать.

Сигнатура (типы параметров, тип возвращаемого значения, const у метода) — это ваша возможность «впечатать» часть договора прямо в код.

const у метода как обещание «я не меняю состояние»

Когда метод интерфейса помечен как const, вы говорите пользователю: «Это операция чтения. Она не меняет наблюдаемое состояние объекта». И это очень сильная гарантия, потому что её нельзя «случайно забыть» в одной реализации и «помнить» в другой.

#include <optional>
#include <string>
#include <string_view>

struct ITaskQueries {
    virtual ~ITaskQueries() = default;

    // Постусловие: не меняет задачи внутри (метод const).
    virtual std::optional<std::string> title_of(std::string_view id) const = 0;
};

Да, реализация может иметь кеши и «служебную» оптимизацию, но для пользователя const означает: «поведение чтения, без изменения логики данных».

string_view как контракт «я не владею строкой»

Когда вы принимаете std::string_view, вы говорите: «Дай мне посмотреть на текст, я не собираюсь забирать его себе».

Это удобно и быстро, но у этого есть важное контрактное следствие: вызывающий код должен обеспечить, что данные живут достаточно долго. В интерфейсе это обычно нормально, потому что строка часто приходит либо из std::string, либо из литерала, а string_view живёт только на время вызова.

#include <string_view>

struct IValidator {
    virtual ~IValidator() = default;

    // Предусловие: view валиден во время вызова.
    // Постусловие: возвращает true только для корректного формата.
    virtual bool is_valid_id(std::string_view text) const = 0;
};

optional как контракт «результата может не быть, и это нормально»

std::optional<T> — это очень «честный» способ сказать: «Иногда результата нет». Это сильнее, чем возвращать пустую строку или -1, потому что пустая строка может быть валидным значением, а -1 иногда бывает валидным ID (в странных проектах — особенно).

#include <optional>
#include <string>

std::optional<int> parse_age(const std::string& s) {
    if (s.empty()) return std::nullopt;
    // Допустим, тут парсинг...
    return 42;
}

Контракт сразу понятен: если nullopt, то «не получилось».

5. Контракт при неуспехе: без исключений, но честно

Мы сегодня сознательно не уходим в исключения и try/catch (это будет отдельный день). Но интерфейсу всё равно нужно определить, что считать ошибкой и как об этом сообщать.

Когда вы проектируете метод, вы почти всегда выбираете один из трёх «языков ответа»: bool — получилось/не получилось; optional<T> — есть результат/нет результата; «значение + флаг» — например, pair<bool, T> (но это часто хуже читается).

Главное — выбрать и закрепить один стиль для данного интерфейса, а не мешать всё в одну кашу.

Например, для операции удаления удобно возвращать bool, потому что результат удаления обычно не несёт данных, но несёт факт:

#include <string_view>

struct ITaskStore {
    virtual ~ITaskStore() = default;

    // Предусловие: id не пустой.
    // Постусловие: true => задача удалена, false => задачи не было.
    virtual bool remove(std::string_view id) = 0;
};

Здесь контракт «железный»: false не означает «ой, у нас сломался диск». Он означает конкретно «не нашли». Если вам нужна детализация причин, это уже другой контракт и другой тип результата, но сегодня мы держим интерфейс простым.

6. Контракт состояния: инварианты и валидность

Хороший интерфейс не только про методы, но и про обещание: «объект остаётся нормальным». В C++ это особенно важно, потому что поломанное состояние легко спрятать, а потом случайно взорвать в другом месте.

Если у вашего хранилища задач есть правило «ID уникален», это часть инварианта. Тогда контракт метода add должен либо запрещать добавление с уже существующим ID (предусловие), либо описывать поведение при конфликте (постусловие). Оба варианта допустимы, но нельзя делать так, чтобы одна реализация «перезаписывала», а другая «игнорировала».

Вот иллюстрация: мы заранее выбираем смысл add и фиксируем его словами.

#include <string>
#include <string_view>

struct Task {
    std::string id;
    std::string title;
};

struct ITaskStore {
    virtual ~ITaskStore() = default;

    // Предусловие: task.id не пустой.
    // Постусловие: если id уже занят, задача не добавляется.
    virtual bool add(const Task& task) = 0;
};

Мы могли бы выбрать и другой контракт («если id уже есть, заменить»), но важно, что он должен быть единым и написанным.

7. Как фиксировать контракт: комментарии, assert, [[nodiscard]]

Контракт полезен ровно настолько, насколько его видно и сложно нарушить. Поэтому в учебных проектах мы обычно комбинируем три вещи: короткий комментарий возле метода, assert для самопроверки в debug-режиме и атрибут [[nodiscard]], когда игнорирование результата почти всегда ошибка.

Комментарий рядом с методом

Это банально, но работает. Причём важна не длина, а конкретность. Не «всё будет хорошо», а «что считается хорошо».

assert как «охранник» предусловий

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

#include <cassert>
#include <string_view>

void require_non_empty(std::string_view s) {
    assert(!s.empty()); // если пусто — кто-то нарушил предусловие
}

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

[[nodiscard]]: «не игнорируй, это важно»

Если метод возвращает bool или optional, почти всегда ошибка — проигнорировать это и продолжить как ни в чём не бывало. Тогда [[nodiscard]] делает контракт дисциплинированнее.

#include <optional>
#include <string>
#include <string_view>

struct ITaskStore {
    virtual ~ITaskStore() = default;

    [[nodiscard]] virtual std::optional<std::string>
    title_of(std::string_view id) const = 0;
};

8. Практический пример: TaskBoard и интерфейс хранилища

Мы продолжаем наше консольное приложение TaskBoard: маленький трекер задач. Раньше у нас уже была модель Task и базовые операции с вектором. Теперь мы хотим сделать так, чтобы «место, где лежат задачи» можно было менять: сегодня это память, завтра файл, послезавтра что-нибудь ещё. Для этого и нужен интерфейс.

Сначала опишем модель и интерфейс. Обратите внимание: здесь мы пока не обсуждаем владение через unique_ptr и фабрики — это будет следующая лекция. Сегодня нам важен именно контракт.

#include <optional>
#include <string>
#include <string_view>
#include <vector>

struct Task {
    std::string id;
    std::string title;
};

struct ITaskStore {
    virtual ~ITaskStore() = default;

    // Предусловие: task.id не пустой.
    // Постусловие: true => задача добавлена, false => id уже занят.
    [[nodiscard]] virtual bool add(const Task& task) = 0;

    // Предусловие: id не пустой.
    // Постусловие: если задача есть — вернёт копию, иначе nullopt.
    [[nodiscard]] virtual std::optional<Task> get(std::string_view id) const = 0;

    // Предусловие: id не пустой.
    // Постусловие: true => задача удалена, false => задачи не было.
    [[nodiscard]] virtual bool remove(std::string_view id) = 0;

    // Постусловие: возвращает снимок всех задач.
    virtual std::vector<Task> list() const = 0;
};

Заметьте важный момент: list() возвращает копию. Это не самый быстрый вариант на свете, но зато контракт простой и безопасный для новичка: кто получил список — тот им владеет, и ему не надо думать о времени жизни ссылок.

Простейшая реализация «в памяти» для проверки контракта

Теперь сделаем реализацию, чисто чтобы проверить, что контракт можно выполнить.

#include <algorithm>
#include <cassert>

struct MemoryTaskStore : ITaskStore {
    std::vector<Task> tasks;

    bool add(const Task& task) override {
        assert(!task.id.empty());
        if (get(task.id).has_value()) return false;
        tasks.push_back(task);
        return true;
    }
};

Да, пока реализован только add, и это нормально для учебного шага: мы показываем, как контракт влияет на код. Видите, как assert напрямую «материализует» предусловие.

Добавим ещё один метод, чтобы стало похоже на жизнь:

std::optional<Task> get(std::string_view id) const override {
    assert(!id.empty());
    auto it = std::find_if(tasks.begin(), tasks.end(),
        [&](const Task& t){ return t.id == id; });
    if (it == tasks.end()) return std::nullopt;
    return *it;
}

И теперь можно сделать маленький main, который использует только контракт, а не детали:

#include <iostream>

int main() {
    MemoryTaskStore store;
    store.add(Task{"T1", "Write C++ code"});           // ok
    store.add(Task{"T1", "Duplicate id"});             // вернёт false (не добавит)

    auto t = store.get("T1");
    std::cout << (t ? t->title : "not found") << '\n'; // Write C++ code
}

Обратите внимание на красоту момента: пользователь ITaskStore (а сейчас это main) точно знает, что означает false у add, и что означает nullopt у get. Ему не нужно «угадывать».

Небольшая схема: как контракт держит интерфейс в рамках

Иногда полезно увидеть это как процесс. Вот простая блок-схема, где контракт выступает «пограничником» между вызывающим кодом и реализацией:

flowchart TD
    A[Вызывающий код] -->|соблюдает предусловия| B[Метод интерфейса]
    B -->|внутри: assert/проверки| C[Реализация]
    C -->|гарантирует постусловия| D[Результат: bool/optional/значение]
    D -->|вызывающий код проверяет результат| A

Если предусловия не соблюдаются, это либо ошибка вызывающего кода (и assert помогает поймать её быстро), либо вы неверно сформулировали контракт и сделали его слишком жёстким для реальности.

9. Типичные ошибки

Ошибка №1: контракт «живёт в голове автора», а не в коде.
Очень часто начинающий разработчик считает, что «и так понятно». Через неделю непонятно уже даже ему самому, а через месяц новый участник проекта «ломает» интерфейс просто потому, что не угадал, что означал false. Лечится короткими предусловиями/постусловиями прямо возле методов и типами результата, которые выражают смысл.

Ошибка №2: один метод делает сразу три разных действия.
Когда метод одновременно «парсит, валидирует и сохраняет», он перестаёт быть точкой контракта: слишком много сценариев успеха/неуспеха. Начинается хаос с тем, что именно гарантируется. Лучше разделить на маленькие операции или хотя бы на операции с одним смыслом, чтобы контракт можно было проговорить одним-двумя предложениями.

Ошибка №3: разные реализации по-разному трактуют «неуспех».
Например, одна реализация remove(id) возвращает false, если не нашла, а другая возвращает true, считая «ну, в итоге же элемента нет». Это логически похоже, но контрактно — катастрофа: вызывающий код не может одинаково работать с обеими реализациями. Нужно выбрать одно значение и зафиксировать его как постусловие.

Ошибка №4: сигнатура говорит одно, а реальность делает другое.
Классика: метод помечен как const, но внутри он «по-тихому» меняет важные данные, и поведение объекта зависит от того, сколько раз вы его читали. Формально это может быть возможно через mutable, но контракт становится мутным. Если метод реально меняет наблюдаемое состояние, он не должен быть const.

Ошибка №5: string_view используют как «хранить навсегда».
std::string_view — это «посмотреть», а не «владеть». Если реализация интерфейса сохраняет string_view внутрь объекта, контракт по времени жизни становится минным полем: вызвали с временной строкой — и привет, висячая ссылка. В интерфейсе string_view хорош для параметров, но внутри реализации обычно нужно копировать в std::string, если вы храните данные.

1
Задача
C++ SELF, 52 уровень, 1 лекция
Недоступна
Счётчик лифта
Счётчик лифта
1
Задача
C++ SELF, 52 уровень, 1 лекция
Недоступна
Проверка логина
Проверка логина
1
Задача
C++ SELF, 52 уровень, 1 лекция
Недоступна
Словарь команд
Словарь команд
1
Задача
C++ SELF, 52 уровень, 1 лекция
Недоступна
Хранилище заметок
Хранилище заметок
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ