Поглощение Groovy 4 проекта GContracts подтолкнуло меня к повторению основных положений о контрактном программировании и его практическом применении, хотя бы и эта версия языка пока находится в глубокой альфе, что допускает будущие изменения и сильные отличия в сравнении с релизом. Но взглянуть на контракты все равно хочется уже сейчас, как и посмотреть на отличия с другими языками.

Как всегда, статья носит целиком исследовательский характер и не претендует на что-то серьезное - очередное маленькое исследование. Экспериментировал с Groovy 4 alpha 2, так что в новых версиях что-то может поменяться.

Начнем сразу с практической части, но возня с бесполезными прямоугольниками уже надоела и хочется чего-нибудь более полезного. Давайте рассчитаем ограничивающий резистор для светодиода по самой упрощенной формуле. Дизайн класса вышел немного упрощенным из-за желания посмотреть на AST-трансформации с разными методами в классе, так что это лишь как условный пример. Помещать коэффициент надежности в статический метод идея такая себе: он запросто может изменяться, например, иметь зависимость от типа светодиода и прочего. Расчет самый простейший и сводится к R = (U питающее - U падения на светодиоде) / (I светодиода * 0,75 коэффициент запаса). Не мешало бы указать, что расчеты проводятся для постоянного тока, как и выделить стратегию, но это лишь очень и очень условный пример.

На выходе получаем сопротивление в Омах, но т.к. оно не привязано к стандартным рядам резисторов, то итоговое сопротивление будет отличаться, поэтому расчет мощности резистора делать тут относительно бесполезная затея. Напомню, класс для исследования разных свойств контрактов, нельзя рассматривать его как пример. Напомню также, что Groovy не Java и при грубом переносе в Java-мир могут быть определенные сюрпризы, хотя на первый взгляд те же операторы выглядят одинаковыми.

Посмотрим, как реализованы контракты в Groovy 4:

import groovy.contracts.*
import groovy.transform.*

@ToString(includeNames = true)
//есть еще специальный метод infinite или же isInfinite(), но он не учитывает NaN. Если же оставить только вторую проверку, то Double.POSITIVE_INFINITY > 0 == true
@Invariant({ Double.isFinite(voltageLedVolt) && voltageLedVolt > 0 && Double.isFinite(currentLedAmp) && currentLedAmp > 0 })
class LedCalculator {
	//оставлю поля свойствами, чтобы посмотреть будет ли срабатывать инвариант для сеттеров
	double voltageLedVolt
	double currentLedAmp

	//как и в других языках инвариант должен проверяться в конце конструктора
	LedCalculator(double voltageLedVolt, double currentLedAmp) {
		this.voltageLedVolt = voltageLedVolt
		this.currentLedAmp = currentLedAmp
	}

	//Идея такая себе, но будет ли генерироваться инвариант для статического поля?
	static double getLedSafetyFactor() {
		return 0.75
	}

	//будет ли генерироваться инвариант для приватного поля?
	private double calculateResistanceOhm(double voltageInCircuitVolt) {
		//Uпит. - Uпад. т.к. метод приватный, то он доверяет значению и не делает дополнительных проверок
		double voltageToReduceVolt = voltageInCircuitVolt - voltageLedVolt
		//учтем, что при делении double / 0.0 исключения не будет (Infinity) и такое грубое округление затрет его, поставим предохранитель на всякий случай, чтобы там не было 0. Как видно, использовать тут подобное округление идея не самая лучшая или нужно отразить это в имени метода.
		assert Double.isFinite(currentLedAmp) && currentLedAmp > 0 && Double.isFinite(getLedSafetyFactor()) && getLedSafetyFactor() > 0
		//(Iсветодиода * 0,75), грубое округление лишь для удобства отладки и распечатки значения. На всякий случай помещу в скобки, чтобы не было вопросов по приоритетам операторов.
		double resistanceOhm = (voltageToReduceVolt / (currentLedAmp * getLedSafetyFactor())).round()
		return resistanceOhm
	}

	//предусловие
	@Requires({ Double.isFinite(voltageInCircuitVolt) && voltageInCircuitVolt > 0 })
	//постусловие, результат в переменной result, доступ к старому значению через old
	@Ensures({ Double.isFinite(result) && result > 0 && old.voltageLedVolt == voltageLedVolt && old.currentLedAmp == currentLedAmp })
	double getResistanceOhm(double voltageInCircuitVolt) {
		//Технически, светодиод может кое-как работать, но при этом расчет не имеет особого смысла
		if (voltageInCircuitVolt < voltageLedVolt) {
			throw new IllegalArgumentException("The voltage in the circuit ${voltageInCircuitVolt}V is less than the LED power supply ${voltageLedVolt}V")
		}
		//совсем без резистора нельзя, вернем 1 Ом
		if (voltageInCircuitVolt == voltageLedVolt) {
			return 1.0
		}
		double resistanceOhm = calculateResistanceOhm(voltageInCircuitVolt)
		return resistanceOhm
	}
}

Взглянем на упрощенный и немного подчищенный результат AST преобразований, можно использовать тот же Groovy AST Browser в GroovyConsole, я оставлю только самое основное, чтобы сократить листинг:

public class LedCalculator extends java.lang.Object implements groovy.lang.GroovyObject { 

	//... поля, но Groovy-свойство превращается в private поле
	static final boolean $GCONTRACTS_ENABLED 

	public LedCalculator(double voltageLedVolt, double currentLedAmp) {
		//установка значений, проверяется инвариант
		this.invariant_LedCalculator()
	}

	public static double getLedSafetyFactor() {
		//инвариант не проверяется
		return 0.75
	}

	private double calculateResistanceOhm(double voltageInCircuitVolt) {
		//только расчет, инвариант тоже не проверяется
		return resistanceOhm 
	}

	@groovy.contracts.Requires(value = LedCalculator$_gc_closure2)
	@groovy.contracts.Ensures(value = LedCalculator$_gc_closure3)
	public double getResistanceOhm(double voltageInCircuitVolt) {
		java.util.Map old = null
		if ( $GCONTRACTS_ENABLED ) {
			old = this.$_gc_computeOldVariables()
		}
		if ( $GCONTRACTS_ENABLED ) {
			try {
				if (org.apache.groovy.contracts.generation.ContractExecutionTracker.track('LedCalculator', 'double getResistanceOhm(double)', 'precondition', false)) {
					java.lang.Boolean $_gc_result = false
					org.apache.groovy.contracts.ViolationTracker.init()
					$_gc_result = new LedCalculator$_gc_closure2(this, this).doCall(voltageInCircuitVolt)
					if (!($_gc_result.booleanValue())) {
						if (org.apache.groovy.contracts.ViolationTracker.violationsOccurred()) {
							try {
								org.apache.groovy.contracts.ViolationTracker.rethrowFirst()
							} 
							finally { 
								org.apache.groovy.contracts.ViolationTracker.deinit()
							} 
						}
					}
				}
			} 
			finally { 
				org.apache.groovy.contracts.generation.ContractExecutionTracker.clear('LedCalculator', 'double getResistanceOhm(double)', 'precondition', false)
			} 
		}
		
		//проверка  voltageInCircuitVolt < voltageLedVolt 
	
		//voltageInCircuitVolt == voltageInCircuitVolt и т.к. там возвращается 1Ом, то проверяются постусловия
		if ( voltageInCircuitVolt == voltageInCircuitVolt ) {
			java.lang.Double result = 1.0
			if ( $GCONTRACTS_ENABLED ) {
				if (!( result > 0 && (( old .voltageLedVolt) as double) == voltageLedVolt && (( old .currentLedAmp) as double) == currentLedAmp )) {
					try {
						assert result > 0 && (( old .voltageLedVolt) as double) == voltageLedVolt && (( old .currentLedAmp) as double) == currentLedAmp : null
					} 
					catch (org.codehaus.groovy.runtime.powerassert.PowerAssertionError error) {
						org.apache.groovy.contracts.PostconditionViolation newError = new org.apache.groovy.contracts.PostconditionViolation('<groovy.contracts.Ensures> LedCalculator.double getResistanceOhm(double) \n\n' + error.getMessage())
						newError.setStackTrace(error.getStackTrace())
						throw newError 
					} 
					finally { 
					} 
				}
			}
			java.lang.Object $_gc_result = result 
			this.invariant_LedCalculator()
			return $_gc_result 
		}
		java.lang.Double resistanceOhm = this.calculateResistanceOhm(voltageInCircuitVolt)
		java.lang.Double result = resistanceOhm 
		if ( $GCONTRACTS_ENABLED ) {
			if (!( result > 0 && (( old .voltageLedVolt) as double) == voltageLedVolt && (( old .currentLedAmp) as double) == currentLedAmp )) {
				try {
					assert result > 0 && (( old .voltageLedVolt) as double) == voltageLedVolt && (( old .currentLedAmp) as double) == currentLedAmp : null
				} 
				catch (org.codehaus.groovy.runtime.powerassert.PowerAssertionError error) {
					org.apache.groovy.contracts.PostconditionViolation newError = new org.apache.groovy.contracts.PostconditionViolation('<groovy.contracts.Ensures> LedCalculator.double getResistanceOhm(double) \n\n' + error.getMessage())
					newError.setStackTrace(error.getStackTrace())
					throw newError 
				} 
				finally { 
				} 
			}
		}
		java.lang.Object $_gc_result = result 
		this.invariant_LedCalculator()
		return $_gc_result 
	}

	@groovy.transform.Generated
	public java.lang.String toString() {
		java.lang.Object _result = new java.lang.StringBuilder()
		//заполнение информации из полей
		java.lang.Object $_gc_result = _result.toString()
		//проверяется инвариант
		this.invariant_LedCalculator()
		return $_gc_result 
	}
	
	@groovy.transform.Generated
	public void setCurrentLedAmp(double value) {
		this.invariant_LedCalculator()
		currentLedAmp = value 
		this.invariant_LedCalculator()
	}

Предусловия аннотируются @Requires, постусловия @Ensures, но а @Invariant и так понятно. Какие выводы можно сделать:

  • Инвариант вызывается в конце конструктора и каждого метода, в т.ч. и метода, созданного через аннотацию @ToString.
  • Инвариант не генерируется для статического поля.
  • Инвариант не генерируется для private и protected методов.
  • Инвариант проверяется при установке поля, а также для синтетического сеттера для свойства setVoltageLedVolt (как и для других). При этом инвариант вызывается как до изменения, так и после изменения поля.
  • Проверяется использование контрактов через $GCONTRACTS_ENABLED. Переменная статическая и заполняется из org.apache.groovy.contracts.generation.Configurator.
  • При сбое контракта выбрасывается org.apache.groovy.contracts.PreconditionViolation (PostconditionViolation, ClassInvariantViolation для разных случаев) в родителях AssertionViolation -> AssertionError -> java.lang.Error -> Throwable. Т.е. это не исключение, не Exception, а ошибка.
  • Для постусловий появляется мапа old из которой можно получить старое значение.
  • return внутри блока выполнить нельзя.
  • Присвоение сделать нельзя: Assignment operators are not supported.
  • it (implicit parameter) тоже не поддерживается: Access to 'it' is not supported.
  • В блоке контрактов вылетает классическое java.lang.NullPointerException, если предусловия пытается проверить null-объект
  • Подсветка ошибок сделана также как и в assert - с максимальным выводом информации для отладки и распечаткой промежуточных значений. Это они молодцы, крайне удобно.

Однако вышло изменить входящие данные. Если сделать что-то такое:

@Requires({!list.isEmpty() && list.removeLast()})
	void test(List<String> list = ["a", "b"]){
		println list
	}
//[a]

Это немного настораживает - легко получить непредсказуемый эффект. Не проверял для инвариантов и постусловий, ибо не думаю, что кто-то в здравом уме будет менять данные в контрактах. С другой стороны, это можно сделать случайно, особенно если методы, которые возвращают булев тип имеют побочный эффект. Но опять таки, подобные методы не тождественны try-parse паттерну и закладывать побочный эффект в простую булеву проверку идея не слишком здоровая.

Если учесть отсутствие вызова инварианта в private\protected-методах и попробовать обойти контракт, переопределяя метод и в нем изменить значение поля, то напарываемся на срабатывания инварианта в сеттерах. Например, для метода расчета в делении не проверяется наличие в делителе 0. Попытаемся отправить его туда, поделив что-нибудь на нуль. Т.к. в предусловии не проверяется значение поля тока, то если обнулить его перед вызовов родительского метода, то расчет по логике вещей должен выйти ошибочным. Заменим модификатор calculateResistanceOhm на protected, переопределим и выйдет что-то такое:

@InheritConstructors
class BlueLedCalculator extends LedCalculator {
	@Override
	protected double calculateResistanceOhm(double voltageInCircuitVolt) {
		//Через direct field access оператор не сработает this.@currentLedAmp = 0 из-за ограничений доступа
		// и так не сработает setProperty('currentLedAmp', 0), инвариант проверяется
		this.currentLedAmp = 0 // и так проверяется
		//в следующем методе не вызывается инвариант, но он срабатывает после изменения поля выше
		double resistanceOhm = super.calculateResistanceOhm(voltageInCircuitVolt)
	}
}
//Ошибка во всех случаях

Тогда осталось попробовать старую добрую рефлексию

@InheritConstructors
class BlueLedCalculator extends LedCalculator {
	@Override
	protected double calculateResistanceOhm(double voltageInCircuitVolt) {
		//'current' здесь не в значении 'текущий', а 'ток'. Возможно, лучший вариант amperage или похожее
		Field currentField = this.class.superclass.getDeclaredField('currentLedAmp')
		assert currentField != null
		currentField.setAccessible(true)
		currentField.set(this, 0)
		double resistanceOhm = super.calculateResistanceOhm(voltageInCircuitVolt)
	}
}
// Assertion failed currentLedAmp > 0

Наконец-то контракт удалось обойти и код упирается в предохранитель в приватном методе. Что бы было, если бы этой подстраховочной подпорки не было... ну, сработало бы постусловие, исключающее побочный эффект, поскольку старое значение не учитывало изменение через рефлексию. Если бы и такого постусловия не было, то тогда в конце метода выполнился бы инвариант currentLedAmp > 0. Если бы вообще не было никаких проверок, то округление бы затерло результат и распечаталась какая-нибудь ерунда, ну а так было бы "Infinity". Как видим, контракты способы создать определенные проблемы в попытке нарушить логику работы класса, что может быть полезным для той же безопасности.

На википедии есть вполне неплохая русскоязычная заметка, из которой вполне можно взять часть определений. Итак, к контракту метода относятся:

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

При этом:

  • Предусловия выполняются перед вызовом метода.
  • Постусловия после вызова.
  • Инварианты же часто употребляются в контексте состояния объекта. Например, для класса, представляющего значение времени на полях может быть проверка, что значения всегда неотрицательные и не выше определенной границы, чтобы там этот класс не делал.

Список достаточно большой и по памяти проверить каждый пункт к каждому методу или классу задача такая себе, им крайне сложно пользоваться. Поэтому все равно так или иначе все сводится к трем элементам: предусловия, постусловия, инвариант. Можно попробовать хотя бы и с логическими нарушениями, но сократить размер списка для запоминания, например:

  • Предусловия, которые могут быть ослаблены (но не усилены) в подклассах.
  • Постусловия, которые могут быть усилены (но не ослаблены) в подклассах.
  • Инварианты, которые могут быть усилены (но не ослаблены) в подклассах.

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

Сначала проверим, можно ли ослабить родительский инвариант, создав наследника с таким инвариантом:

@InheritConstructors
//очень грубая проверка, которая не покрывает другие случаи
@Invariant({ voltageLedVolt != 0 })
class BlueLedCalculator extends LedCalculator {
}

def ledCalc = new BlueLedCalculator(Double.NaN, Double.NaN)
//Ошибка, тестируется родительский контракт

Ослабить инвариант не выходит, сначала тестируется родительский. Его можно только усилить.

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

@InheritConstructors
class BlueLedCalculator extends LedCalculator {
	@Override
	@Requires({ Double.isFinite(voltageInCircuitVolt) })
	double getResistanceOhm(double voltageInCircuitVolt) {
		//сразу не сработает: return super.getResistanceOhm(-12)
		//переводим в абсолютное значение
		double voltageValue = voltageInCircuitVolt.abs()
		return super.getResistanceOhm(voltageValue)
	}
}

//Напомню, что в Groovy дефолтные типа BigDecimal и в случае активации статической проверки такой каст может вызвать ошибку.
def ledCalc = new BlueLedCalculator(2.5.toDouble(), 0.02.toDouble())
println ledCalc.getResistanceOhm(-12)
//633

Контракт получилось ослабить, однако если бы отрицательное число попало в родительский метод, то его контракт был бы провален. Для вызова super.getResistanceOhm(-12) контракт в родительском методе не позволил бы провести расчет из отрицательного значения. Это может намекнуть на тонкие баги в случае делегирования, прокси-методов и т.п. из-за несовпадения контрактов.

Попробуем ослабить теперь постусловие.

@InheritConstructors
class BlueLedCalculator extends LedCalculator {
	@Override
	//попробуем перезаписать контракт, оставив только одну проверку
	@Ensures({ result > 0 })
	double getResistanceOhm(double voltageInCircuitVolt) {
		//и отсыпем в метод немного побочных эффектов, которые в таком методе явно не положены
		this.voltageLedVolt -= 0.01
		this.currentLedAmp -= 0.01
		return super.getResistanceOhm(voltageInCircuitVolt)
	}
}
BlueLedCalculator ledCalc = new BlueLedCalculator(2.5.toDouble(), 0.02.toDouble())
println ledCalc.getResistanceOhm(12)

//Ошибка, родительский контракт не дает изменить поля класса

В итоге выходит, что:

  • Инвариант и постусловия работают как контракт предка && контракт потомка. Т.к. проверка контракта предка всегда выполняется, то нельзя как-то ослабить ограничения.
  • Предусловия как контракт потомка || контракт предка. Потомок может ослабить ограничения, но если он усилит их, то сработает контракт предка.

Осталось немного пофилософствовать о самом понятии контрактов и особенностях применения этого достаточно специфического инструмента. Немного переработанное ради упрощения определение с вики может выглядеть как-то так:

Контрактное программирование — метод проектирования программного обеспечения, основанный на определении формальных, точных и верифицируемых спецификаций интерфейсов для компонентов системы, посредством добавления к обычному определению абстрактных типов (АТД) еще и контрактов - предусловий, постусловий и инвариантов.

По смыслу этого определения система (приложение) разделяется на компоненты, имеющие интерфейсы со спецификациями. Сразу на ум приходит структурная UML-диаграмма, которая также оперирует понятием "интерфейс" и под компонентом может подразумевать какой-то модуль, из которого состоит система.

Сама же спецификация UML понимает интерфейс как декларирующий общедоступные функции и обязательства, определяя при этом контракт, который должен выполнить тот, кто его реализует. При этом связанные с ним обязательства представляют собой форму ограничений (constraints) - пред\пост условий, спецификаций протоколов, что упорядочивают взаимодействие. В еще более общем случае под интерфейсом можно понимать вообще какой-либо именованный набор услуг, который запрашивается "потребителем" и предоставляется "поставщиком".

C другой стороны, в языках существует свой интерфейс, как абстрактный тип, определяющий условия, что должны быть реализованы его имплементацией, разве что дефолтные методы, не требующие обязательной реализации немного нарушают этот принцип обязательности. При объяснении особенностей интерфейса частенько можно услышать мнение, что они задают некий "контракт", который обязаны выполнять классы, его реализующие. Очевидно, что приложение вообще может не иметь привычных интерфейсов-типов по разным причинам, поэтому нельзя сказать, что интерфейсы языка тождественны концептуальным.

Кроме того, на интерфейс похож и абстрактный класс. Можно найти бесконечное множество попыток их друг от друга разграничить, но все это упирается либо в особенности языка, а как видим в разных языках подходы немного различаются, либо в кружение вокруг да около не слишком четких архитектурных понятий и терминов, тех же контрактов. В той же Java до эпохи дефолтных методов одним из различий обычно упоминали лишь возможность задекларировать методы, но потом все поменялось, а значит это не слишком подходящий признак. Итого, привязываться к реализации себе дороже и интерфейс в понятии контрактов скорее всего нужно рассмотреть все же со стороны моделирования, без привязки к его реализации в конкретном языке. Рассмотрим следующее понятие из определения контрактов - спецификацию.

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

Однако строгость и характеристики требований, которая выдвигает спецификация плохо согласуется со скудной природой контракта в обычном интерфейсе, как и в абстрактом классе. Декларацией метода в общем случае трудно обязать реализующий класс соблюдать и другие ограничения: выброс непроверяемых исключений, наличие побочного метода и т.п.

Если взять язык, который эти ограничения более-менее может суммировать, например, D, то одновременной установкой нескольких атрибутов для какого-нибудь сеттера свойства можно получить даже слишком строгие ограничения. Код будет работать, по методу очень легко определить, что и как он делает, но простейшая отладка через распечатывание значения переменной уже проблематична и потребует ослабления этих ограничений. С одной стороны, это может и не потребоваться, а с другой - неплохой пример поправки на то, что взаимодействует с кодом все же человек и слишком жесткие ограничения способны породить свои собственные проблемы.

И в русскоязычном и в англоязычном определении термина сделан акцент на совместную работу АТД и самих контрактов. Появляется закономерный вопрос: насколько сильно отделены контракты как инструмент от АТД, в которых используются более простые средства языка.

Например, часто инструментом контракта выступает assert, исключение или же какой-нибудь класс ошибки, который выбрасывается из внутренностей библиотек. Часто упоминается для этой цели и различные подходы к типам в языке, что может говорить о чрезвычайно широкой трактовке самого понятия "контракт", как вообще все, что обеспечивает спецификацию интерфейсов.

С одной стороны, есть мнение о разграничении обработки ошибок и корректности самой программы, когда assert используется строго для второго, но это подходит разве что для математических задач, спецификация которых относительно четкая и легко прослеживаемая.

В отношении более сложных предметных областей все может быть труднее, как и для случая запутанных-перезапутанных состояний в том же графическом интерфейсе, отследить которые обычным графом или специализированными приемами, вроде state transition testing иногда вообще невозможно, они непредсказуемы.

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

Как вариант, проблемы могут прилететь из-за различия поведения поставщика и потребителя. Рассмотрим простейшую цепочку вызовов, где в самом простом случае если метод A возвращает результат, которые передается в метод B, то A может обеспечить более строгие постусловия, тогда B не нужно ничего проверять и его предусловия могут быть мягче. С другой стороны, появляется зависимость B от A через контракт, когда как другой метод может вернуть совсем иные данные, например, вредоносные и B окажется совершенно беззащитным перед такой атакой. Проверки без отключения выглядят как-то понадежнее чем временные, в которых легко что-то упустить, не учесть особенности поставщика, какой-то нюанс или потерять их совсем.

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

В зависимости от смысла трактовок получаем на практике множество затруднений что и когда использовать, специализированные контрактные инструменты или средства языка. Если контракт не выбрасывает исключение, а выбрасывает ошибку, то несмотря на то, что ошибка может наследоваться от более общего типа, например, Throwable, но все равно по общему принципу обработки исключений это не тот класс ошибок, которые разрешено перехватывать в коде и приложение ляжет. Когда как обычное исключение может быть перехвачено. При грубой замене старых добрых проверок на контракты есть шанс получить очень и очень хрупкое приложение, которое будет ложится при малейшем чихе. Теоретически, это может быть вполне логичным решением, но в суровых реалиях падающее приложения от использования некритичного функционала выглядит странным.

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

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

С другой стороны, если отделить их от основной части кода метода, что читаемость может сильно повыситься, кроме того, это может использовать IDE при подсветке документации. Документация текстом часто не выдерживает особой критики из-за постоянной изменяемости кода и большого количества различных вызовов, где каждый требует требует проверки доков и выискивания в них нюансов, особенно когда они не отмечены вообще. Однако у подобного подхода логика проверок будет порой разделяться между кодом и контрактом, создавая шанс либо неполностью, либо неправильно трактовать контракт, такая контрактная документация также размазывается, как и обычная.

Контракты становятся очень полезными в случае наследования, накладывая дополнительные ограничения на потомков. С другой стороны, на практике наблюдается постоянное расхождение между большим количеством разных противоречащих друг другу требований и кодом, что требует определенной доли гибкости, особенно от библиотечного кода. Контракты способны не только переусложнить код когнитивно, но и создать ровно такие же слишком агрессивные ограничения, как финализация или как модификатор доступа private. Для автора библиотеки это удобно, но для клиентского кода это чревато огромными проблемами вплоть до провала всего проекта если аналогов библиотеки не существует, она закрыта или сложна для изменений, поэтому нужен определенный баланс.

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

Подходящими ситуациями выглядят:

  • Особо критичные и высоконагруженные участки работы с финансовой информацией, деньгами и прочим, где цена ошибки очень высока. Тогда переусложнение и постоянные проблемы из-за нестыковки контрактов разных методов друг с другом компенсируются дополнительными подстраховкамии и улучшением производительности при отключении контрактов. Учитывая, что в вычисления могут ненароком попасть разные типы, результат оказаться не числом или выскочить еще какой сюрприз, то возня с контрактами выглядит как оправданная дополнительная подстраховка. Разве что в первых версиях контрактов могут быть (будут) баги, что явно уменьшит выигрыш от них.
  • Классы с состояниями, таймеры и прочие, где отследить комбинацию состояний очень сложно. Заменить чем-то инвариант проблематично, а вызов метода запросто может привести объект в какое-нибудь неожиданное состояние с непредсказуемым поведением.
  • Наличие большого количество методов, изменяемые поля для null-небезопасного языка. В таком случае проверку на null нужно добавлять в каждый метод, что неудобно, либо же она может создавать множество накладных расходов для производительности. Есть аннотации вроде той же @NullCheck из Groovy 3, но она была крайне багованной и работа зависела от наследуемых конструкторов и вообще от положения звезд так что неплохо бы иметь альтернативу.

Последний случай с null на полях пересекается еще и с валидацией объектов, где можно вспомнить тот же JSR 380. При этом инвариант контракта предполагает нахождение объекта всегда в рамках ограничений, когда как валидация на определенном этапе жизненного цикла может быть более гибким решением. В таком случае на промежуточных этапах в поле может быть невалидное значение, которое легко упустить.

С другой стороны, если данные полей более-менее однородны или они помечены\аннотированы, то можно не беспокоиться за изменение полей - выбрать все и если какое-то поле выглядит подозрительным, то бросить ошибку. С инвариантом тут хлопот будет больше и нет гарантий, что поле не будет забыто, разве что дублировать контракт валидатором тоже.

Хорошая ли идея использовать старое значение из old и накладывать ограничение, например, на побочный эффект как в примере выше с расчетом резистора. Как и в случае с преимуществом валидации количество полей может быть очень большим и они будут постоянно изменяться\удаляться\добавляться, так что такой подход явно теряет в своей универсальности. В отличие от какого-нибудь D по сигнатуре метода с контрактом проблематично сразу узнать, защищает ли он от побочных эффектов или нет, например, проверяется только одно поле, а их на самом деле гораздо больше, так себе идея. Сравнение старого и нового значений можно использовать, например, для ограничения изменения поля на какую-либо величину, проверяя для инкремента, что новое - старое < x.

С другой стороны, если не брать в расчет гонок или подобных сюрпризов, то логичнее сначала поверить, что значение попадает в допустимый диапазон, а только потом изменять само поле. Проверять после изменения выглядит немного странным, хотя вылетевшая ошибка и защищает код от невалидного состояния такого объекта, но эту ошибку вполне возможно перехватить и допустить использование объекта с неверным значением.

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