В воскресенье я как обычно бездельничал, просматривая Reddit. Прокручивая игры щенков и плохой юмор программистов, один конкретный пост привлёк мое внимание. Речь шла о баге в calc.exe.
Неверный результат вычисления диапазона дат в Калькуляторе Windows
«Ну, это похоже на любопытную ошибку, интересно, что может её вызвать», — подумал я про себя. Количество недель, безусловно, делает баг похожим на какую-то ошибку переполнения или задания диапазона, ну вы знаете, типичные причины. Но это всегда может быть какой-то перевёрнутый бит каким-то высокоэнергетическим лучом от какого-то дружественного космического соседа.
Заинтересовавшись причиной, я сделал то, что вы делаете в таких случаях: попробовал на своей машине, чтобы запостить «У меня всё работает». И повторение ситуации из поста «31 июля − 31 декабря» на моей машине дало правильный результат «5 месяцев». Но немного потестировав, я обнаружил, что «31 июля – 30 декабря» на самом деле вызывает ошибку. Выводится не совсем корректное значение «5 месяцев, 613566756 недель, 3 дня».
Я ещё не закончил расшатывать программу и тут вспомнил: «О, а разве калькулятор — не одна из тех вещей, для которых Microsoft открыла исходники?» И действительно. Эта ошибка не могла быть слишком сложной, поэтому я подумал, что попробую найти её. Скачать исходники было достаточно просто, и добавление требуемой рабочей нагрузки UWP в Visual Studio также прошло без сучка и задоринки.
Навигация по кодовым базам, с которыми вы не знакомы, — это то, к чему привыкаешь со временем. Особенно когда вы хотите внести вклад в проекты с открытым исходным кодом, где находите баг. Однако незнание XAML или WinRT, конечно, не облегчает дело.
Я открыл файл solution и заглянул в проект “Calculator” в поисках любого файла, который должен иметь отношение к багу. Нашёл DateCalculator.xaml
, затем вроде бы подходящий по названию DateDiff_FromDate to DateCalculatorViewModel.cpp
и, наконец, DateCalculator.cpp
.
Установив точку останова и посмотрев некоторые переменные, я увидел, что конечное значение DateDifference
уже неверно. То есть это была не просто ошибка преобразования в строку, а ошибка фактического вычисления.
Фактическое вычисление в упрощённом псевдокоде выглядит примерно так:
DateDifference calculate_difference(start_date, end_date) {
uint[] diff_types = [year, month, week, day]
uint[] typical_days_in_type = [365, 31, 7, 1]
uint[] calculated_difference = [0, 0, 0, 0]
date temp_pivot_date
date pivot_date = start_date
uint days_diff = calculate_days_difference(start_date, end_date)
for(type in differenceTypes) {
temp_pivot_date = pivot_date
uint current_guess = days_diff /typicalDaysInType[type]
if(current_guess !=0)
pivot_date = advance_date_by(pivot_date, type, current_guess)
int diff_remaining
bool best_guess_hit = false
do{
diff_remaining = calculate_days_difference(pivot_date, end_date)
if(diff_remaining < 0) {
// pivotDate has gone over the end date; start from the beginning of this unit
current_guess = current_guess - 1
pivot_date = temp_pivot_date
pivot_date = advance_date_by(pivot_date, type, current_guess)
best_guess_hit = true
} else if(diff_remaining > 0) {
// pivot_date is still below the end date
if(best_guess_hit)
break;
current_guess = current_guess + 1
pivot_date = advance_date_by(pivot_date, type, 1)
}
} while(diff_remaining!=0)
temp_pivot_date = advance_date_by(temp_pivot_date, type, current_guess)
pivot_date = temp_pivot_date
calculated_difference[type] = current_guess
days_diff = calculate_days_difference(pivot_date, end_date)
}
calculcated_difference[day] = days_diff
return calculcated_difference
}
Выглядит нормально. В логике проблем нет. По сути, функция делает следующее:
- отсчитывает полные годы от стартовой даты
- с момента даты последнего полного года отсчитывает месяцы
- с момента даты последнего полного месяца отсчитывает недели
- с момента даты последней полной недели отсчитывает оставшиеся дни
На самом деле проблема заключается в предположении, что последовательный запуск
date = advance_date_by(date, month, somenumber)
date = advance_date_by(date, month, 1)
равен
date = advance_date_by(date, month, somenumber + 1)
Обычно это одно и то же. Но возникает вопрос: «Если вы попали на 31-е число месяца, в следующем месяце 30 дней, вы прибавляете один месяц, то куда попадёте?»
Похоже, для Windows.Globalization.Calendar.AddMonths(Int32) ответ будет «на 30-е число».
А это значит, что:
«31 июля + 4 месяца = 30 ноября»
«30 ноября + 1 месяц = 30 декабря»
«31 июля + 5 месяцев = 31 декабря»
Таким образом, операция AddMonths не является ни дистрибутивной (с AddMonth-умножением), ни коммутативной, ни ассоциативной. Какой вообще-то должна быть операция «сложения». Разве не весело работать со временем и календарями?
Почему в данном случае ошибка задания диапазона приводит к такому огромному числу месяцев? Как вы могли догадаться, это возникает из-за того, что days_diff
является беззнаковым типом. Это превращает -1 дней в огромное количество, которое затем передаётся на следующую итерацию цикла с неделями. Которая затем пытается исправить ситуацию, уменьшая current_guess
, но не уменьшая беззнаковую переменную.
Что ж, это был интересный способ провести воскресенье. Я создал пулл-запрос на Github с минимальным «исправлением». Я ставлю «исправление» в кавычки, потому что теперь вычисление выглядит так:
Думаю, технически это правильный результат, если считать, что «31 июля + 4 месяца = 30 ноября». Хотя такой вариант не совсем согласуется с человеческой интуицией о разнице дат. Но в любом случае это менее неправильно, чем было.
Автор: m1rko