В очередном 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. Но это уже совсем другая история.