Введение

Многие из вас, вероятно, слышали об упоминаний контрактов во время обсуждения кода. Фразы наподобии: "Код должен соблюдать контракт интерфейса", "Юнит-тестами тестируется не код, а контракт класса", "Тестируйте не код, а контракты" и т.п. Сегодня постараемся понять: что такое контракты и что они дают. Статья будет состоят из двух частей:

  1. Введение в контракты, что такое контракты, свойство контрактов и т.д

  2. Примеры использование контрактов в коде и объяснение о том, что определенные выражения об контрактах (пример: "Тестировать нужно контракты, а не реализацию" и т.п).

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

Содержание:

  1. Что такое контракты, аналогии в реальном мире

  2. Что такое контракт в коде

  3. Из чего состоит контракт: постусловия, предусловия и инварианты

  4. Виды контрактов

Что такое контракт?

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

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

  • Что мы ожидаем от того, с кем мы заключили контракт, т.е то что он должен выполнить

  • Какие последствия будут если наш контракт будет нарушен

  • И т.д

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

И это понятие контракт из реального мира, попытались внедрить в код. Теперь объясним что такое контракт в рамках кода.

Что такое контракт в коде?

Контракт - это описание “правил” взаимодействия сущностей друг с другом в рамках кода. Примеры "правил" для класса бывают примерно такими: “метод этого класса обязуется предоставить результат, если будут выполнены все условия”. Если привести более понятный пример с кодом:

Допустим, у нас есть класс ProductService и у него метод uploadImage, и если попытаться составить контракт к этому методу, то он будет выглядит так:

Метод выполнится если:

  • Передан аргумент file - который является типом N-класса

    • В случае передачи неправильного типа - выходит ошибка

  • Переданный аргумент file имеет вес меньше 5мб

    • В случае передачи неправильного типа - выходит ошибка (исключение)

  • И т.д

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

В настоящее время, прописать контракт для метода становится легче, потому что в языки введены, type hint (указание типов передаваемых аргументов), возможность указать тип возвращаемого результата и т.д.

В последствии, писать проверки в теле метода на подобии: аргумент “a” соответствует типу “б” теряет актуальность, все это теперь работает на уровне ядра языка. Но из-за этого не снизилась значимость контрактов, потому что не все условия можно указать в аргументах.

Свойства контракта:

Обычно контракт состоит из некоторых свойств, с помощью которого описывается контракт:

  • Предусловия

  • Постусловия

  • Инварианты

Предусловия - это условие которое, мы выполняем в теле метода, до выполнение основного действия. Все это описывается именно в методе, с помощью простых if и т.д. Пример (будет псевдокод, укороченный PHP):

class ProductService 
{
    
    public function uploadImage(FileUpload file): bool 
    {
      // Предусловия
      if (file.size > 5000) {
          throw new Exception('File size is more than 5mb');
      }

      // Выполнение основного кода
      // ....
    }
    
}

Думаю из примера понятно, что тут ничего сложного нету, предусловия - это проверки перед выполнение основного кода метода.

Постусловия - это такие же условия как и предусловия только наоборот. Под “наоборот”, я имею ввиду, что они выполняются после выполнение основного кода метода, перед возвращением результата. Тот же пример:

class ProductService 
{

    public function uploadImage(FileUpload file): bool 
    {
      // Предусловия
      if (file.size > 5000) {
         throw new Exception('File size is more than 5mb');
      }

      // Выполнение основного кода		
      result = ......;
		
      // Постусловия
      if (!result) {
         throw new Exception('Fail during upload file');
      }

      return true;
   }

}

Инварианты - это проверки на то, что состояние объекта является валидным после изменения. Допустим пример, у нас есть класс Balance (Баланс) и метод который обновляет значение баланса. Инвариант в этом случае будет проверка состояния объекта (в нашем случае баланса), находится ли он в дозволенном, нашей системой состоянии.

Пример:

class Balance 
{
    protected balance;

    /**
     * @param int value
     * @throw Exception
     * @return bool
    */
    public function changeBalance(int value): bool 
    {
        // ....
        // Основной код ...
         
        // Инвариант
        this.validateBalance();
        
        return result.response;  
    }
    
    
    /**
     * @throw Exception
     * @return void
    */
    public function validateBalance(): void
    {
        if (this.balance < 0) {
            throw new Exception('Balance cannot be less than 0');
        }    
    }
    
        
}

Если мы обновим наш баланс с помощью метода changeBalance, то состояние нашего объекта изменится, и инвариант в этом случае будет проверка на то, что наш баланс не меньше 0. То есть теперь определение будет более понятно, инварианты — это специальные условия, которые описывают целостное состояние объекта.

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

Если рассматривать контракты в PHP, их там можно придерживаться только с некоторыми ограничения, проблема в том, что везде писать проверки на инварианты сложно (но есть реализации библиотек которые работают с помощью Reflection API, применяя парсинг док-блоков). Обычно программирование на контрактах называются “Контрактное программирование”.

Разные виды контрактов:

По-моему мнению, контракт можно описать двумя способами:

  1. Контракт который описан с помощью интерфейса

  2. Контракт который описан с помощью реализации без интерфейса или с интерфейсом.

Сейчас поговорим об их различии.

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

interface ModuleA 
{

	/**
	 * @param int value
	 * @throw RuntimeException
	 * @return boolean
	*/
	public function update(int value): bool;

}

В этом примере, мы описали контракт с помощью док-блока и с помощью type hint. Контракт метода звучит так:

  1. Передаваемый параметр value, должен иметь тип int

  2. Возвращаемое значение метода будет типом boolean

  3. Метод может выкинуть исключение RuntimeException

Как мы видим, мы описали контракт, но он неполноценен. Неполноценен, из-за того что, нету возможность описать свойства контракта (предусловия, постусловия и инварианты). Теперь рассмотрим пример со вторым видом контракта, которые лишен этих минусов.

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

class ModuleA 
{
		
    protected value;
    
    /**
	   * @param int value
	   * @throw RuntimeException
	   * @return boolean
	  */
    public function update(int value): bool 
    {
        if (value.length < 40) {
           throw new RuntimeException('...');
        }
        
        // Основной код ....
         
        if (!result) {
           throw new RuntimeException('...');
        }
          
        return result.response;
    }
	
}

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

Из двух этих примеров, ясно, что первая реализация, через интерфейс, менее конкретна, чем вторая, в котором полностью видно, как нужно взаимодействовать с классом.

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

Итог:

Контракты в программировании - это описание то, как будет ввести себя модуль (класс, метод и т.д), с помощью контрактов мы получаем удобства, такие как:

  • Понимание того, какого рода аргументы нужно передать в метод

  • Понимание того, что нас ожидает в случае ошибки (какого рода ошибки будут кидаться)

  • Обязывают разработчиков писать код в рамках контракта

  • Понимание того, как нужно взаимодействовать с нашим кодом

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

Описание контрактов можно разделить на два вида: первый - описание контракта с помощью интерфейса, второй - описание контракта с помощью реализации класса.

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

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

Вот на этом все, если заметили ошибку или неточность пишите в комментариях. Можете также писать о вашем понимании контрактов.

Советую прочитать эти ресурсы для хорошего понимания материала:

  • Прочитать книгу “Адаптивный код”, глава по SOLID, там пролистать до третьего принципа, O - OCP и там описываются контракты

  • https://youtu.be/oMi2ReGtXrI - посмотреть это видео, более подробно описывает контракты и показывает пример на PHP.

  • https://habr.com/en/post/214371/ - другая статья, про контракты и как они реализованы в PHP, также даётся список библиотек которые позволяют описать свойства контракта в док-блоке

  • Прочитать про "Контрактное программирование" и как реализуется в других языках

Комментарии (7)


  1. DVF
    23.03.2022 21:37
    +1

    Мы не слышали об упоминаний контрактов. Аналогие в реальном мире нет. Продоёте?

    Вычитывайте тексты, пожалуйста.


  1. iStyx
    24.03.2022 01:34
    +3

    В коде комментарии к пред- и постусловиям перепутаны местами. А ещё в PHP переменные начинаются с символа $.

    А ещё throw Expection(...) должно быть throw new Exception(...).


  1. Octember
    24.03.2022 06:44

    		if (file.size < 5000) {
    					throw Expection('File size is more than 5mb');
    		}

    Многовато ошибок для трех строк. Переменные начинаются с $, не хватает new, не Expection, а Exception, логика неверная, if проверяет, что размер меньше 5000, а не больше

    Пример проверки на состояние:

    Пример куда-то потерялся.

    В целом статья неплохая, но ошибки очень мешают воспринимать информацию


    1. obitel_016 Автор
      24.03.2022 07:12

      Благодарю за замечания. Исправлю, допустил ошибки из-за невнимательности.


    1. Maksclub
      25.03.2022 08:55

      похоже на псевдокод, тк точка для обращения к переменной класса, видимо Джаву имитировали некоторым псевдокодом, это нормально


  1. Steepik
    24.03.2022 15:50

    Очень не привычно когда пишут не верный синтаксис :)


    1. obitel_016 Автор
      24.03.2022 15:53

      Хотел не привязываться к конкретному языку, но синтаксис все равно больше на PHP похож, только отсутствует всеми любимый - '$'