1. Введение, индукция
  2. Метод единственного сходства
  3. Метод единственного различия
  4. Объединенный метод сходства и различия
  5. Метод сопутствующих изменений
  6. Метод остатков
  7. Примеры
  8. Условно-категорический силлогизм
  9. Выводы
Введение, индукция

Когда мы сталкиваемся с багами или странным поведением кода, то первым делом начинаем устанавливать причину происходящего. В самом простом случае хорошо работает известный прием "закомментировал-запустил-раскомментировал". По мере увеличения количества зависимостей справиться с ситуацией становится всё тяжелее. Особенно это характерно для предметно-ориентированных (DSL) языков, где малый отклик от кода может привести к большим потерям времени и за что многие сильно их не любят.

Но даже в тяжелых случаях часто используется экспериментальный метод "тыка" - перебор любых значений на которые можно как-то повлиять или изменить с наблюдением результата. Вспомним о методологии, которая лежит в самой основе.

На самом деле есть отдельная теория познания, тесно связанная с логикой, философией и другими науками, отчего всё это приобретает разбросанный характер знаний. Однако при специализации методы обычно теряют в своей универсальности, а нас, конечно же, интересуют самые простые и общие основы, применимые для самых разных случаев, поэтому мы рассмотрим их ровно так, как это подается почти в каждом учебнике по логике.

Чтобы не вырывать методологию из контекста вспомним из базового курса логики несколько определений.

Форма мышления - способ связи элементов мысли, выражающий её структуру и содержание. К формам мышления относятся понятие, суждение и умозаключение. Нас интересует умозаключение.

Умозаключение – это форма мышления, с помощью которой из ранее установленных знаний можно получить новое знание разной степени достоверности. Они бывают дедуктивные - от общего к частному, индуктивные - от частного к общему и традуктивные (умозаключение по аналогии) - от знания к знанию такого же уровня. Нас интересуют индукция.

Часто индукцию делят на полную и неполную, добавляя и более специализированные виды, например, математическую. Остановимся на первых двух:

  • Полная индукция - общий вывод обо всех элементах делается на основании рассмотрения каждого элемента, т.е. изучаются все предметы данного класса. Выводы носят достоверный характер.
  • Неполная индукция – на основании повторяемости признака у некоторых элементов множества делается заключение о его принадлежности всем элементам. Вывод не является достоверным.

Нас интересует неполная индукция, её виды:

  • Популярная - простое перечисление при отсутствии противоречащего случая.
  • Научная (индукция путем отбора) - обобщение строится путем отбора необходимых и исключения случайных обстоятельств. Научная индукция пытается повысить достоверность выводов неполной индукции различными специальными методами.

Научная индукция, в свою очередь, бывает:

  • Селективная - планомерный и целенаправленный отбор, на основе определенной системы критериев.
  • Индукция методом исключения (элиминативная) – обнаружение подтверждающих обстоятельств и исключения обстоятельств, не удовлетворяющих свойствам причинной связи.

Последняя особенно явно подводит нас к понятию причин и следствий, которые опираются на интуитивные и простые постулаты:

  • Каждое явление имеет причину, которую можно и нужно установить.
  • Причина наступает во времени раньше, чем следствие.
  • После причины непременно наступает следствие.
  • При отсутствии причины следствие не наступает.
  • Изменения в причине приводят к соответствующим изменениям в следствии.

Нас как раз таки и интересуют эти методы установления причинно-следственных связей, которые часто называют методами Бэкона-Милля, по именам их разработчиков. Даже саму научную индукцию иногда называются индукцией Бэкона-Милля (или Каноны Милля).

В методы Бэкона-Милля входят:

Метод единственного сходства

Если обстоятельство постоянно предшествует исследуемому явлению при изменении всех других обстоятельств, то вероятно, что именно оно и является причиной этого явления.

Примерный алгоритм:

  • Определить все случаи с явлением a, причина которого неизвестна.
  • Выделить все обстоятельства, связанные с появлением явления a.
  • Найти общее для всех случаев обстоятельство. Именно оно, вероятно, и будет причиной появления явления a.

Формально:
АВС→а
АDЕ→а
АКМ→а
----------------------
◊(А→а), где ◊ - оператор возможности из модальной логики, подразумевает вероятностный характер вывода

Следовательно, обстоятельство А, вероятно, является причиной явления а. Общим для всех случаев было обстоятельство A.

Метод единственного различия

Если определенное обстоятельство присутствует тогда, когда имеет место исследуемое явление, и отсутствует тогда, когда это явление отсутствует (а все другое остается неизменным), то именно это обстоятельство и является вероятной причиной исследуемого явления.

Примерный алгоритм:

  • Рассмотреть два случая, в одном из которых исследуемое явление a наступает, а во втором — не наступает.
  • Сравнить эти случаи и выявить обстоятельства, которые им предшествовали.
  • Выявить общие обстоятельства и обстоятельство, которое отсутствует в случае, когда явление а не наступает. Именно оно и будет причиной его появления.

Формально:
АВСD→а
ВСD→
----------------------
◊(А→а)

К сожалению, методы единственного сходства и единственного различия очень ненадежны на практике и приводятся часто лишь для демонстрации основы метода, который является их комбинацией:

Объединенный метод сходства и различия

Если два (или более) случая, в которых имеет место исследуемое явление, сходные только одним обстоятельством, которое предшествует возникновению этого явления, а два (или более) случая, в одном из которых это явление отсутствует, а в другом присутствует, отличаются только отсутствием (присутствием) этого обстоятельства, то, вероятно, это обстоятельство и является причиной исследуемого явления.

Примерный алгоритм:

  • Рассмотреть несколько случаев, в которых наступает явление а. Эти случаи во всем отличны и только схожи в одном обстоятельстве А.
  • Рассмотреть несколько случаев, в которых не наступает явление а. Эти случаи отличаются от предыдущих тем, что в них отсутствует общее обстоятельство А.

Формально:
АВС→a           АВСD→а
АDЕ→а           ВСD→
----------------------
◊(А→а)

Метод сопутствующих изменений

Если при изменении одного из обстоятельств, которые предшествуют возникновению исследуемого явления, изменяется и само явление, то вероятно, что именно это обстоятельство и является причиной этого явления.

Примерный алгоритм:

  • Рассмотреть случай, когда возникает явление а, и выявить обстоятельства, присущие ему.
  • Изменить несколько раз одно из обстоятельств и посмотреть, изменяется ли явление а. Если каждый раз изменялось и явление а, то, вероятно, это обстоятельство и является причиной возникновения этого явления.

Формально:
АВСD→аbcd
А1ВCD→а1bcd
А2BСD→а2bcd
...
АnBСD→аnbcd
----------------------
◊(А→а)

К сожалению, из-за большого количества зависимостей для любого явления отделить изменение причины и следствия достаточно трудно. Поэтому вне технических наук этот метод может легко дать ошибочный результат.

Метод остатков

Если сложные обстоятельства обусловливают сложное явление и известно, что часть обстоятельств вызывает определенную часть этого явления, то оставшиеся обстоятельства вызывают оставшуюся часть явления.

Здесь даже стоит привести классический пример, наиболее часто я встречал описание открытия планеты Нептун. До его открытия движение Урана рассчитывалось с предположением о том, что единственными небесными телами, которые влияли на него были Солнце и планеты в орбите самого Урана. Однако рассчитываемое местоположение Урана не совпадало с расчётами. Была выдвинута гипотеза о существовании неизвестной планеты, гравитационное поле которой и обуславливало эти различия, определено её примерное положение и недалеко от этого места в 1846 году был обнаружен Нептун.

К сожалению, метод остатков считают самым слабым из известных методов научной индукции. Эффективность его очень сильно зависит от независимости обстоятельств, составляющих явление. Если эти обстоятельства зависят друг от друга, то всё становится намного сложнее.

Примерный алгоритм:

  • Сложное явление ab обусловлено обстоятельствами AB.
  • Явление а возникает по причине обстоятельства А.
  • Следовательно, В, вероятно, является причиной b.

Формально:
АВС→аbc
А→а
B→b
----------------------
◊(С→с)

Примеры

Что можно сказать о самой методологии. Она совершенно не отвечает на вопрос, каким способом нужно проводить анализ и выдвигать гипотезу о зависимости, которую затем проверять с помощью методов индукции. При большом количестве различных явлений достаточно трудно управлять их анализом, поэтому можно грубо предположить, что в случае более низкоуровневых языков это сделать проще за счет их пошагового характера. Среди языков с наиболее меньшим количеством зависимостей является язык ассемблера (ну, или ассемблеры в случае трансляторов), а так как наиболее прост он в обращении под Linux, то я буду использовать NASM для 64-разрядной системы. Я не рассматриваю сборку и запуск - это легко гуглится, а комментировать буду над вызовами, иначе на мобильных устройствах за счет большой ширины кода это всё некрасиво поедет. Кроме того, я буду придерживаться краткости, (например, помещать в регистр единицу напрямую вместо инкремента) и ориентироваться на сборку через GCC, ибо последние версии идут с дефолтным PIE\PIC, что требует некоторой доработки кода.

Давайте вспомним очень частую ошибку, связанную с различием между вызовом функции\метода в высокоуровневом языке. Мы просто распечатаем число с вызовом ptintf, но изначально поместим переменную в регистр Rx, чтобы они хранились в регистрах, на первый взгляд, не задействованных напрямую при вызове. Мы знаем (нагуглили, прочитали), что для вызова потребуется RDI - для паттерна, RSI - для значения и обнуленный RAX для возврата результата вызова. Мы может получить что-то вроде этого:

; Секция данных
section .data

; db - данные как последовательность байт. 10 - перенос строки, 0 - нулевой байт, конец строки. Но NASM понимает и `Message=%d\n`
messageFormat: db "Message=%d", 10, 0

; Секция кода
section .text

; Объявляем точку входа. В зависимости от способа сборки точка входа может изменяться.
global main

printNumber:
; Здесь, в прологе процедуры нам не нужен стековый фрейм, обойдемся без выравнивания и переменных
; Значение для печати должно находится в RSI, мы сделаем это в main
; Помещаем в RDI адрес паттерн сообщения 
mov rdi, messageFormat

; Обнуляем RAX для предотвращения сегфолта. Конечно же, похожий аналог: mov rax, 0
xor rax, rax
; Вызываем printf, в последних версиях GCC по умолчанию включен позиционно-независимый код, поэтому обычный вызов приведет к ошибке. Ну, или нужно отключать через -no-pie.
call printf WRT ..plt
; После вызова стоит проверить RAX на наличие отрицательного числа - признак ошибки и что-нибудь сделать.
; Выходим из процедуры
ret

; Точка входа
main:
; Помещаем в регистр R8 число 5, чтобы число было в регистре, не задействованном при вызове printf.
mov r8, 5

; В RSI число 5
mov rsi, r8
; Печатаем 5
call printNumber

; Метка с точкой, локальная для main
.exit:
; Помещаем в RDX код выхода, под Linux он 0<= и <=255. В данном случае мы не проверяли ошибки и обнуляем его, в ином случае терять ошибки, конечно же, нельзя
xor rdx, rdx
; Помещаем номер вызова sys_exit из ядра: https://blog.rchapman.org/posts/Linux_System_Call_Table_for_x86_64/
mov rax, 60 
; Запрашиваем выход из программы 
syscall

Итак, мы ожидаем печати числа 5. Запустим в терминале и убедимся, что это действительно так:

Message=5

Усложним задачу и сделаем два вызова подряд


mov r8, 5
mov r9, 6

; В RSI число 5
mov rsi, r8
; Печатаем 5
call printNumber

; В RSI число 6
mov rsi, r9
; Печатаем 6
call printNumber

Мы ожидаем распечатать два числа 5 и 6. Что мы видим, регистр R9 где-то потерялся:

Message=5
Message=1849455520

За счет пошаговой природы ассемблера достаточно легко выявить проблему, закомментировав часть строк. Обычно комментируется большой кусок кода, потом меньше и меньше, напоминая своей природой чем-то двоичный поиск. У нас немного зависимостей, поэтому комментируем первый вызов т.к. первый вызов дал правильный результат.


mov r8, 5
mov r9, 6

; В RSI число 5
; mov rsi, r8
; Печатаем 5
; call printNumber

; В RSI число 6
mov rsi, r9
; Печатаем 6
call printNumber

Используем методы выше и полностью комментируем первый вызов и наблюдаем, что в регистре R9, а значит и в RSI действительно число 6

Message=6

Раскомментируем снова и получаем неправильный ответ. Здесь явная зависимость от вызова call, при появлении вызова или его отсутствии проблема появляется и исчезает соответственно. Далее мы можем уточнить в процедуре, что вызов printf каким-то образом теряет регистры, загуглить и почитать, что регистры вполне себе могут теряться вплоть до R11. Но за счет простого комментирования мы быстро выявили проблему.

Метод сопутствующих изменений достаточно ясен, а вот метод остатков попробуем натянуть на данный код. Не уверен, что это будет хорошим примером, но попробуем распечатать теперь число с плавающей точкой. Нельзя так просто взять и поместить его в регистр, но можно инициализировать переменную, причем она должна быть размером не меньше чем dword т.е. не менее 4 байт. Используем 8-байтный тип qword по величине регистра, чтобы не иметь проблем с разными размерами и поменяем спецификатор формата для printf на %f:

section .data
messageFormat: db "Message=%f", 10, 0
; Используем директиву задания исходных данных для qword - dq.
floatNumber: dq 123.45

Теперь нам нужна прямая адресация, которая в NASM задается скобками, поместим в регистр RSI наше число, учитывая требования PIC используем rel и попытаемся напечатать:


; В RSI число 123.45
mov rsi, [rel floatNumber]
; Печатаем 123.45
call printNumber

Запускаем:

Message=0.000000

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

  • Помещение значения числа в регистр RSI
  • Вызов нашей процедуры
  • Помещение паттерна в RDI
  • Обнуление RAX
  • Вызов printf
  • Выход

Вычеркнем события, который происходят при любой процедуре - вызов, выход и т.п.

  • Занесение числа в регистр RSI
  • Обнуление RAX

Если мы рассмотрим их по алгоритму выше, то эти два события мы не трогали, поэтому можно предположить, что проблема кроется в каком-то из них, они вызывают ошибку, когда как другие события мало влияют на её появления, они слишком очевидны. Здесь уже можно сформулировать запрос в Google и узнать, что число должно быть в специфическом регистре, а RAX требует 1. На самом деле, конечно же, это можно было бы загуглить и сразу.

Исправляем, проще использовать регистр расширения SSE, но так как он 128-битный, то мы используем movq для пересылки 64-битного нашего числа типа qword:

section .data
messageFormat: db "Message=%f", 10, 0
floatNumber: dq 123.45

section .text

global main

printNumber:
mov rdi, messageFormat
mov rax, 1
call printf WRT ..plt
ret

main:
; В xmm0 число 123.45
movq xmm0, [rel floatNumber]
; Печатаем 123.45
call printNumber

.exit:
mov rdi, 0
mov rax, 60 
syscall

Итог:

Message=123.450000

Да уж, такой себе пример, но, думаю, Вы поняли принцип аналогии. На самом деле он не только притянутый за уши, но и надуманный, мы ничего не знаем - зависимые ли там события или нет. Всё это достаточно неочевидно и если такое происходит в простом ассемблере, то что будет в сложном высоко абстрактном языке в условиях тысяч классов убер-фреймворка, сборщиков, системных библиотек, особенностей предметной области и просто наличия там множества багов? Логика подсказывает, что в условиях сложных систем нужна адаптация приложения или фреймворка для получения большей обратной связи. Но кому это всё нужно. Попробуйте, например, включить логгер в Spring (который не Boot) и проследить какой-нибудь компонент Spring Security, а потом снова случайно поймаете запрет запросов GET, но разрешенный POST при запрете всех запросов вообще. Отловить это можно скорее лишь агрессивным тестированием.

Условно-категорический силлогизм

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

  • Если процедура printNumber запускается с числом 123.45.
  • В консоль печатается 123.45.
    Или: Если A, то B.

Конечно, тут подразумевается, что процедура срабатывает корректно и не имеет ошибок. Однако и в этом случае можно сделать неверный вывод. Например, в консоль распечаталось 123.45, можно ли сказать, что процедура printNumber сработала? Нет. Условно-категорический силлогизм имеет только два правильных модуса:

Утверждающий модус или modus ponens: от утверждения основания к утверждению следствия:
Если A, то B
A
----------------------
B
Т.е. мы можем сказать, что если корректная процедура точно запустилась, то в консоль будет напечатано число.

Отрицающий модус, он же modus tollens: от отрицания следствия к отрицанию основания:
Если A, то B
Не B
----------------------
Не A
Т.е. мы можем сказать, что если в консоль не выводится наше число, то процедура не запускалась (напомню, что мы предполагаем её корректность)

А вот дальше интереснее и рождается серьезная опасность принять вероятностные выводы за правдоподобные, чему способствуют следующие модусы:
Если A, то B
B
----------------------
Вероятно, A
Это очень коварная ловушка. Действительно, если распечатывается число, то могла сработать совершенно другая процедура или не выполняться вообще. В топе источников проблем находятся побочные эффекты кэшей и баги сборки. Скрипт сборки и запуска может иметь ошибку и запускать старый код вместо нового, например, неправильно определяя время изменения файла или иметь ошибку парсинга его имени. Мы комментируем вызов процедуры, но за счет того, что исполняется старый код все равно получаем в консоль наше злосчастное число. Такие ситуации встречаются повсеместно и наиболее коварны из них те, которые визуально не нарушают методы индукции, изящно маскируя проблему. Технически, проблемы с кэшем или сборкой способны исказить картину любых модусов в т.ч. правильных, но я все же должен был вспомнить об этом.

И похожий модус на предыдущий:
Если A, то B
Не-A
----------------------
Вероятно, не-B
Если процедура не запускается, то число все равно может распечататься по причинам выше.

Выводы

Таким образом, данная методология индукции вводит хотя бы и примитивную, но всё-же системность в тыкание по коду. К сожалению, использование сложных математических методов, как правило, слабо применимо из-за неравномерного уровня восприятия, внимания, памяти, мышления и т.п. психологических процессов, поэтому самые простые идеи, как правило, часто могут спасти ситуацию. Кроме того, их рассмотрение полезно еще с одной стороны. В глаза бросается некая связь с количеством зависимостей и отловом багов. Чем больше менее важных событий мы можем отбросить, тем быстрее начнем экспериментировать с важными, которые скорее всего и будут причиной бага. В части примеров выше я сразу отбросил помещение в регистры адреса, выход из процедуры и т.п. Поэтому вспомним заодно о контрактном программировании.

В коде выше нет удобного способа узнать, что делает, а чего не делает printf. Нам следовало бы задекларировать для нашей обертки printNumber: вход, выход, сохранение регистров, использование макросов, вызов других процедур, обращение к ядру и т.п. с учетом побочных эффектов, чтобы сделать поведение предсказуемым. Но в условиях ассемблера сделать это можно разве что документацией, а любая документация также имеет известные недостатки, увеличивающиеся вместе со сложностью приложения. Неплохо было бы, чтобы на уровне языка мы могли сразу отбрасывать какие-то события, например, побочные эффекты.

С другой стороны, иногда эти же побочные эффекты могут и специально добавляться. Например, в Java (и не только) распространенной практикой является создание иммутабельных коллекций в т.ч. через более простую инициализацию, например, как List.of(1, 2, 3), которые может быть трудно отличить от обычных в коде, однако они имеют совершенно разные контракты. С одной стороны, это может быть оправданным из-за распространенности и каждый разработчик знает, что там может быть внутри, с другой - может привести к минированию приложения, которое подорвётся в рантайме в случайный момент времени. Например, ошибка потери исключения достаточно распространена за счёт перегрузки методов логгера. Вы перехватываете исключение, логируете его что-то вроде log.error("bla-bla") вместо log.error("bla-bla", e) и оно не выходит за пределы catch блока уже никогда. Это бывает достаточно сложно обнаружить, а в условиях отсутствия статического анализатора или инспекций можно долго думать, что вызывает проблему, ну не добавление же элемента в коллекцию. Обычная практика помещать все эти нюансы в комментарии или покрывать тестами не выдерживает особой критики в условиях сурового боевого, а тем более одиночного программирования.

Противоположный подход развивает, например, D, где кроме непосредственно самих пред\постусловий с инвариантами по сигнатуре метода можно определить есть ли в нём побочные эффекты или изменение состояния, есть ли там опасные операции или нет. Кроме того, D не позволяет мешать изменяемое с неизменяемым и наоборот, хотя и скомпилировать его код сложнее, но в будущем намного проще при поиске бага сразу отбросить многие проблемные ситуации. С другой стороны, агрессивные навязываемые контракты в D могут создать очень серьезные проблемы при переопределении методов или последующем усложнении кода. Как всегда, золотой середины нет и у каждого подхода есть свои недостатки.

К чему я веду. Вышеперечисленные методы поиска причин и следствий, конечно же, очень сильно расходятся с практикой. Кроме того, на это влияет не только язык, но и когнитивные особенности самого разработчика, которые в течении дня обычно нестабильны, связаны с усталостью, сложностью предметной области и другими переменными. Вспомню достаточно показательный случай, который произошел со мной в D, раз уж я упомянул о нём. Работа с сетью в D построена на библиотеке curl. Тестируя приложение в Linux я обнаружил его краш. Ошибка стабильно воспроизводилась при добавлении импорта std.net.curl, сама библиотека с зависимостями была установлена. И тут я пошел по ложному следу. Я думал, что её причина в неправильной сборке и попытке передать неверные флаги компилятору и линкеру. Пробуя разные способы сборки я обнаружил, что dmd файл компилирует без ошибок и предположил, что проблема в неправильной передаче флагов через Dub, начав экспериментировать с ним. Убедившись через лог, что они передаются без ошибок и строка dmd примерно эквивалентна моей строке с одиночного его запуска я начал искать другие различия и наконец-то обнаружил несколько зависимостей, который были подключены в dependencies у dub.json и о которых я совершенно забыл. Одна из них и оказалась причиной, после её удаления всё заработало. Проблема был в том, что её имя содержало слово "lib", что в сообщении о краше затирало намек на проблему зависимостей, досадно...

С другой стороны, даже метод "тыка", как мы убедились, можно подвести под какую-то системность, а сам он находит свое отражение и в других концепциях. Будем надеяться, что в скором времени наука изобретёт одновременно эффективные и простые способы, которые можно применять на практике с учетом усталости и снижения внимания, создавая код вообще без каких-либо багов и проблем. На этом пожелании и остановимся.