На этой неделе я хотел бы заняться интересным подходом, который я редко видел, но он является очень полезным.
Дизайн по контракту, также известный как контрактное программирование, является подходом к разработке программного обеспечения. Он предписывает, чтобы разработчики программного обеспечения определяли формальные, точные и проверенные спецификации интерфейса для программных компонентов, которые расширяют обычное определение абстрактных типов данных с предусловиями, постусловиями и инвариантами. Эти спецификации называются «контрактами», в соответствии с концептуальной метафорой с условиями и обязательствами деловых контрактов.
Wikipedia
По сути, условия прерывают работу. Нет смысла запускать код, если в конце, вычисление завершится неудачно из-за неправильного предположения.
Давайте рассмотрим пример операции передачи между двумя банковскими счетами. Вот некоторые условия:
Пред-условия:
- Передаваемая сумма должна быть положительной.
Константы:
- Исходный банковский счет должен иметь положительный баланс.
Пост-условия:
- Баланс счета исходного банка должен быть равен начальному балансу, за вычетом суммы перевода.
- Баланс целевого банковского счета должен быть равен начальному балансу плюс переведенная сумма.
Реализация «вручную»
Легко реализовать пред- и пост-условия «вручную»:
public void transfer(Account source, Account target, BigDecimal amount) {
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgument("Amount transferred must be higher than zero (" + amount + ")";
}
if (source.getBalance().compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgument("Source account balance must be higher than zero (" + source.getBalance() + ")";
}
source.transfer(target, amount);
if (source.getBalance().compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalState("Source account balance must be higher than zero (" + source.getBalance() + ")";
}
// Other post-conditions...
}
Такой код является громоздким и трудно читаемым.
Реализация на Java
Возможно, вы уже работали с пред- и пост-условиями с помощью ключевого слова assert:
public void transfer(Account source, Account target, BigDecimal amount) {
assert (amount.compareTo(BigDecimal.ZERO) <= 0);
assert (source.getBalance().compareTo(BigDecimal.ZERO) <= 0);
source.transfer(target, amount);
assert (source.getBalance().compareTo(BigDecimal.ZERO) <= 0);
// Other post-conditions...
}
Существует несколько проблем при использовании Java-подхода:
- Разница между пред- и пост-условиями отсутствует
- Код должен быть запущен при помощи флага запуска
-ea
Документация Oracle прямо указывает на это:
Хотя конструкция assert не является полноценной конструкцией по контракту, она может помочь поддерживать неформальный стиль программирования по контракту.
Альтернативная реализация на Java
Начиная с Java 8, класс
Objects
предлагает три метода, которые накладывают ограничения на программирование по контракту:-
public static <T> T requireNonNull(T obj)
-
public static <T> T requireNonNull(T obj, String message)
-
public static <T> T requireNonNull(T obj, Supplier<String> messageSupplier)
Аргумент Supplier
в последнем методе возвращает сообщение об ошибке
Все 3 метода бросают NullPointerException
, если obj
равно null
. Более интересно то, что они возвращают, если
obj
не равно null
. Это приводит к следующему виду кода:public void transfer(Account source, Account target, BigDecimal amount) {
if (requireNonNull(amount).compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgument("Amount transferred must be higher than zero (" + amount + ")";
}
if (requireNonNull(source).getBalance().compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgument("Source account balance must be higher than zero (" + source.getBalance() + ")";
}
source.transfer(target, amount);
if (requireNonNull(source).getBalance().compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalState("Source account balance must be higher than zero (" + source.getBalance() + ")";
}
// Other post-conditions...
}
Мало того, что это накладывает ограничения, так и ухудшает читаемость кода, особенно если вы добавляете аргумент сообщения об ошибке.
Реализации для определённых фрэймворков
Spring Framework предоставляет класс
Assert
, который предлагает множество методов проверки состояния:В соответствии с собственными реализациями, проверки пред-условия вызывают исключение
IllegalArgumentException
, если условие не выполняется, тогда как проверки после состояния бросают исключение IllegalStateException
.На странице Википедии выше также перечислены несколько фрэймворков, посвященных программированию по контракту:
Большинство из вышеперечисленных фрэймворков основаны на аннотациях.
Плюсы и минусы аннотаций
Начнем с плюсов: аннотации делают условия очевидными.
С другой стороны, аннотации не лишены недостатков:
- Они требуют манипуляции с байт-кодом либо во время компиляции, либо во время выполнения
- Они довольно ограничены по своему охвату (например, Email)
- Переводят на внешний язык, который настроен как атрибут строки аннотации
Kotlin-подход
Программирование на Kotlin по контракту основано на простых вызовах метода, сгруппированных в файле
Preconditions.kt:
-
require
методы реализуют пред-условия, а если их нет, то будет брошеноIllegalArgumentException
-
check
методы реализуют пост-условия, а если их нет, то будет брошеноIllegalStateException
Переписать вышестоящий фрагмент при помощи Kotlin довольно просто:
fun transfer(source: Account, target: Account, amount: BigDecimal) {
require(amount <= BigDecimal.ZERO)
require(source.getBalance() <= BigDecimal.ZERO)
source.transfer(target, amount);
check(source.getBalance() <= BigDecimal.ZERO)
// Other post-conditions...
}
Заключение
Поскольку это частый случай, то чем проще, тем лучше. Просто завернув проверку и бросаемые исключения в метод, можно легко использовать программирование по концепциям контракта. Хотя таких оболочек нет в наличии на Java, valid4j и Kotlin предлагают их.
Спасибо за внимание, до новых встреч!
Комментарии (7)
potan
03.04.2018 10:05А есть ли системы формальной верификации, поддерживающие данные контракты? То есть, например, что бы по коду с контрактами можно было сгенерировать задание для SMT-солвера, которое ищет нарушение контракта?
vampirit
03.04.2018 15:10"По сути, условия быстро перестают работать. Нет смысла запускать код, если в конце, вычисление завершится неудачно из-за неправильного предположения."
Я бы сказал не "условия перестают работать", они все таки не перестают.
А "условия прерывают работу".
Спасибо за перевод
SlavniyTeo
05.04.2018 10:49Заинтересовавшимся программированием по контракту рекомендую почитать статьи Сергея Теплакова здесь.
Ztare
05.04.2018 13:35Ведь очень многое можно проверять статически и заставлять вызывающего программиста убеждаться в правильности параметров вызова. Когда уже до этого дойдет научная мысль встроить более жесткие контракты прямо на уровень объявления методов, а не рандомными падениями в проде при неверных данных.
POPSuL
05.04.2018 14:43Статья интересная, но меня немного смутил последний пример (я не пишу на котлине, хотя немного тыкал его):
require(amount <= BigDecimal.ZERO) require(source.getBalance() <= BigDecimal.ZERO)
Мне кажется что логичнее было бы использовать
>=
вместо<=
. Я, как человек не знающий как реализована функцияrequire()
, прочитал это как "требуется чтобы сумма была меньше или равна нулю, и чтобы баланс переводящего был меньше или равен нулю".
Как по мне, поведение классической функцииassert()
является более логичным.
visirok
Спасибо за интересную статью.
В этом напралении остаётся большой потенциал для роста. Например, нетривиальные ограничения на параметры метода хорошо бы сделать видимыми для программиста, его использующего (без анализа исходного кода). Если это делать вручную в комментариях, нет гарантии что проверка имплементирована и имплементирована правильно.
Просто брошенные Exceptions при нетривиальных проверках также могут быть малоинформативны.