пятница, 26 июня 2009 г.

[comp] Доверие к “правильному” коду

В очередном RSDN-новском флейме на тему unit-тестирования возникло противопоставление тестирования кода и разработки гарантированно корректного кода (с помощью “правильных”, т.е. функциональных языков программирования). Продемонстрировать это можно на примере, который я подкинул в это обсуждение. Итак, есть некий псевдо-C++ код для перевода денег с одного банковского счета на другой:

void transfer(
  Account & from,
  Account & to,
  const Money & amount )
  {
    // RAII в действии. Конструктор блокирует объект,
    // а деструктор разблокирует.
    AccountLock lock_from( from );
    AccountLock lock_to( to );

    from.debit( amount );
    to.credit( amount );
  }

Данный код является потенциально подверженным взаимным блокировкам. Например, если первая нить вызовет transfer(A,B,m), а вторая transfer(B,A,n).

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

LockPair< Account > transfer(
  Account & from,
  Account & to,
  const Money & amount )
  {
    // Этот объект гарантирует, что захват ресурсов
    // выполняется в нужном порядке.
    LockPair< Account > locks( lock_objects( from, to ) );

    from.debit( amount );
    to.credit( amount );

    return locks;
  }

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

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

Однако, не все так просто. Поскольку никто не застрахован от ошибок, и в реализацию transfer могла закрасться маленькая опечаточка:

LockPair< Account > locks( lock_objects( from, from ) );

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

К чему я это? Да к тому, что тестам по барабану, какие ошибки искать и в соответствии с какими принципами разрабатывался тестируемый код. Они просто показывают, есть ли ожидаемый результат или нет. Тогда как изначально задумывавшийся в качестве корректного код может оказаться проблемным из-за опечатки. И это не будет выявлено как раз из-за отсутствия тестов, поскольку разработчик посчитал, что его “правильный” код в тестировании не нуждается.

Ситуация напоминает известное выражение: практика – единственный критерий проверки теории. Этот принцип, насколько я знаю, успешно применяется в науке. И хотелось бы, чтобы он применялся и в программировании. Поскольку “правильные” подходы к разработке программ – это теория. Которая на практике может разлететься в пух и прах по самым разным причинам. Начиная от тупости использующих эти подходы разработчиков и элементарных опечаток, и заканчивая фундаментальными просчетами в самой теории.

PS. Этот пример интересен еще и тем, во что может выливаться в коде попытка писать гарантированно корректный код. Так, возвращаемое значение для transfer теперь показывает наружу детали работы transfer. А что, если нам это не нужно? Ведь мы можем писать код, которому не важно, работает ли он в многопоточной или однопоточной программе. Но тем не менее, мы будем закладываться на то, что реализация transfer связана с блокировками. Далее, если уж мы начали пытаться что-то гарантировать, то нужно идти дальше, и гарантировать, что операции debit и credit не могут вызываться для незаблокированных объектов Account. А это значит, что спецификация этих операций должна измениться, например, на такую:

void debit(
  const Amount & money,
  const Lock & lock );

чтобы программист не мог вызвать debit/credit без предварительного блокирования счета. Хотя и это еще не гарантия правильной реализации transfer. Но это уже совсем другая история.

Отправить комментарий