В воскресенье я как обычно бездельничал, просматривая Reddit. Прокручивая щенячьи забавы и плохой юмор программистов, моё внимание привлёк один конкретный пост. Речь шла о баге в calc.exe.

Неверный результат вычисления диапазона дат в Калькуляторе Windows
«Ну, это похоже на любопытную ошибку, интересно, что может её вызвать», — подумал я про себя. Количество недель, безусловно, делает баг похожим на какую-то ошибку переполнения или задания диапазона, ну вы знаете, типичные причины. Но это всегда может быть какой-то перевёрнутый бит каким-то высокоэнергетическим лучом от какого-то дружественного космического соседа.
Заинтересовавшись причиной, я сделал то, что вы делаете в таких случаях: попробовал на своей машине, чтобы запостить «У меня всё работает». И повторение ситуации из поста «31 июля − 31 декабря» на моей машине дало правильный результат «5 месяцев». Но немного потестировав, я обнаружил, что «31 июля – 30 декабря» на самом деле вызывает ошибку. Выводится не совсем корректное значение «5 месяцев, 613566756 недель, 3 дня».
Я ещё не закончил расшатывать программу и тут вспомнил: «О, а разве калькулятор — не одна из тех вещей, для которых Microsoft открыла исходники?» И действительно. Эта ошибка не могла быть слишком сложной, поэтому я подумал, что попробую найти её. Скачать исходники было достаточно просто, и добавление требуемой рабочей нагрузки UWP в Visual Studio также прошло без сучка и задоринки.
Навигация по кодовым базам, с которыми вы не знакомы, — это то, к чему привыкаешь со временем. Особенно когда вы хотите внести вклад в проекты с открытым исходным кодом, где находите баг. Однако незнание XAML или WinRT, конечно, не облегчает дело.
Я открыл файл solution и заглянул в проект “Calculator” в поисках любого файла, который должен иметь отношение к багу. Нашёл
Установив точку останова и посмотрев некоторые переменные, я увидел, что конечное значение
Фактическое вычисление в упрощённом псевдокоде выглядит примерно так:
Выглядит нормально. В логике проблем нет. По сути, функция делает следующее:
На самом деле проблема заключается в предположении, что последовательный запуск
равен
Обычно это одно и то же. Но возникает вопрос: «Если вы попали на 31-е число месяца, в следующем месяце 30 дней, вы прибавляете один месяц, то куда попадёте?»
Похоже, для Windows.Globalization.Calendar.AddMonths(Int32) ответ будет «на 30-е число».
А это значит, что:
«31 июля + 4 месяца = 30 ноября»
«30 ноября + 1 месяц = 30 декабря»
«31 июля + 5 месяцев = 31 декабря»
Таким образом, операция AddMonths не является ни дистрибутивной (с AddMonth-умножением), ни коммутативной, ни ассоциативной. Какой вообще-то должна быть операция «сложения». Разве не весело работать со временем и календарями?
Почему в данном случае ошибка задания диапазона приводит к такому огромному числу недель? Как вы могли догадаться, это возникает из-за того, что
Что ж, это был интересный способ провести воскресенье. Я создал пулл-запрос на Github с минимальным «исправлением». Я ставлю «исправление» в кавычки, потому что теперь вычисление выглядит так:

Думаю, технически это правильный результат, если считать, что «31 июля + 4 месяца = 30 ноября». Хотя такой вариант не совсем согласуется с человеческой интуицией о разнице дат. Но в любом случае это менее неправильно, чем было.

Неверный результат вычисления диапазона дат в Калькуляторе 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 ноября». Хотя такой вариант не совсем согласуется с человеческой интуицией о разнице дат. Но в любом случае это менее неправильно, чем было.
