Бажная психология
Исправлять баги трудно.
В контексте этой статьи я говорю о достаточно «явных» багах – тех изъянах, которые целиком вызваны неспособностью программиста корректно реализовать некое механистическое вычисление или гарантировать выполнение постусловия. Я не говорю про ой, мы только что узнали, что название продукта звучит как матерное слово на Урду, или спецификация была не вполне точной, так что мы ее поменяли, или про код, оказавшийся недостаточно устойчивым при некорректных вызовах. Я имею в виду те баги, где вас попросили вычислить значение и вы просто получили неверный результат для некоторых верных аргументов.
Давайте я приведу пример.
Первый баг, который я починил на полной ставке в Microsoft был одним из них. Чтобы понять контекст этого бага, начните с чтения этой статьи из ранних дней моего блога, и возвращайтесь сюда.
С возвращением! Надеюсь, вам эта поездка по памятным местам понравилась так же, как и мне.
Теперь, когда вы понимаете, как хранится VT_DATE, можно объяснить это странное поведение VBScript:
print DateDiff("h", #12/31/1899 18:00#, #12/30/1899 6:00#) / 24
print DateDiff("h", #12/31/1899 18:00#, #12/29/1899 6:00#) / 24
Этот код выведет -1.5 и -2.5, как и ожидается. Между шестью утра тридцатого декабря и шестью вечера тридцать первого – полтора дня, а между другой парой дат – два с половиной. Это понятно. Но если вы просто вычтете даты:
print #12/31/1899 18:00# - #12/30/1899 6:00#
print #12/31/1899 18:00# - #12/29/1899 6:00#
То получится 1.5 и 3, а не 1.5 и 2.5. Из-за противоестественного формата даты, выбранного VT_DATE, при конвертации дат в числа их нельзя безопасно вычитать, если они пересекают волшебную нулевую дату. Вот почему вам нужны полезные методы вроде DateDiff, DateAdd и так далее.
Назначенный мне баг состоял в том, что тестеры обнаружили конкретную пару дат, которые DateDiff вычитал некорректно. Я глянул в исходный код одного из вспомогательных методов, которые DateDiff вызывает в процессе своих вычислений. Для моих свежевыпущенных из колледжа глаз, это смотрелось примерно так:
if (frob(x) > 0 && blarg(y)) return x – y;
else if (frob(x) < blarg(y) && blah_blah(x) > 0 || blah_de_blah_blah_blah(x,y)) return frob(x) – x + y + 1;
else if…
И так семь раз.
Первым моим побуждением было смело ввязаться в драку и добавить восьмую особую ветку, которая бы чинила баг. Но меня беспокоила моя способность сделать всё как следует при такой сложности. Это уже выглядело как чрезмерно запутанная для своей задачи функция.
Я немножко поисследовал историю кода, и обнаружил, что фактически вариации этого бага были обнаружены… семь раз. Каждый особый случай в коде соответствовал конкретному багу, который был «исправлен», если можно так сказать в этом случае.
Многие из этих «исправлений» на самом деле внесли новые баги, ухудшая существующее корректное поведение, что, в свою очередь, «исправлялось» добавлением особой обработки поверх особых обработок, добавленных для «исправления» предыдущих багов.
Я решил, что этот программерский ужас закончится здесь. Я удалил весь код (все семь строк! Я был смел!) и начал сначала.
Глубокий вдох.
Сначала запишем требования к коду. Затем спроектируем код в соответсвии с требованиями. Затем напишем код по проекту.
Требования:
- Вход: два double, представляющих даты в формате VT_DATE.
- Формат VT_DATE: целая часть (со знаком) представляет количество дней, прошедших с 30.12.1899, дробная часть без знака соответствует доле дня, прошедшей с тех пор.
- Например: -1.75 = 18:00 29.12.1899
- Выход: double, содержащий количество дней, возможно дробное, между двумя датами. Различия, связанные с летним/зимним временем и т.д , игнорируются
Стратегия проектирования:
- Проблема: некоторые числа нельзя просто вычитать, потому что отрицательные даты не представляют абсолютные смещения относительно начала эпохи.
- Поэтому, сконвертируем все даты в более осмысленный формат, который поддаётся простому вычитанию.
Код:
double DateDiffHelper(double vtdate1, double vtdate2)
{
return SensibleDate(vtdate2) – SensibleDate(vtdate1);
}
double SensibleDate(double vtdate)
{
// отрицательные даты типа –2.75 означают “иди назад на два дня, потом вперёд на.75 дня”:
// преобразуем это в –1.25, имея в виду “иди назад 1.25 дня”.
return DatePart(vtdate) + TimePart(vtdate);
}
У меня уже были вспомогательные методы DatePart и TimePart, так что на этом я закончил. Новый код был короче, гораздо более читабелен, генерировал более компактный и быстрый машинный код, и, что самое важное, был явно корректен. Никаких особых случаев; никаких багов.
Не то чтобы мои сослуживцы были дурнями. Далеко нет. Это были умные люди. Но психология компьютерных гиков такова, что очень легко сосредоточиться непосредственно на неправильной штуке, и попытаться подкрутить её, пока она не начнёт нормально работать.
Когда я встречаю такой вид «явных» багов, я стараюсь сдерживаться от немедленного начала работы. Вместо этого я стараюсь провести психоанализ человека – обычно, конечно же, себя самого из прошлого, – который допустил ошибку. Я спрашиваю себя, «как человек, написавший ошибочный код, обманулся и решил, что он корректен?» Была ли у него спецификация на то, что должен был делать метод? Вводила ли она в заблуждение? Был ли у него чёткий план работы? Если да, то где всё пошло не так?
Если ни спецификации, ни плана никогда не было, то вся штуковина может работать только при счастливом стечении обстоятельств. В ней может быть произвольное количество изъянов дизайна, которые просто еще не вышли на свет. Редактирование такого монстра означает добавление неизвестности к неизвестности. Что редко приводит к хорошим результатам. Иногда разработка новой спецификации, нового плана, и выброс существующего клоповника – лучший путь вперед.
В течение многих лет после того случая, я спрашивал, как реализовать DateDiffHelper , в качестве технического вопроса на собеседованиях свежевыпущенных из колледжа кандидатов в команду разработки скриптинга. Я решил, что если это была задача, полученная мной в первый день в офисе, то, возможно, это будет приемлемый вопрос для кандидата.
Когда задаешь один и тот же вопрос снова и снова, то начинаешь видеть значительную разницу в способностях разных кандидатов. У меня были кандидаты, которые просто хватали маркер, писали решение прямо на доске, записывали тесты, которыми они бы его проверили, прогоняли несколько тестов в уме, и потом у нас было еще полчаса потрепаться о погоде. И были такие, кто честно пытался написать версию на основе частных случаев, несмотря на то, что я специально говорил им «можете рассмотреть трансформацию этого неудачного формата во что-то такое, с чем приятнее работать», после того, как они встревали на третьем частном случае. Я указывал на баг, и они сразу писали код для еще одного случая, вместо того, чтобы остановиться и подумать над тем фактом, что они только что трижды написали неверный код и объявили его корректным.