Формальна верифікація для смертних

💡 Усі статті, обговорення, новини про Python — в одному місці. Приєднуйтесь до Python спільноти!

Коли запитуєш, як переконатися у тому, що програма працює як треба, можна почути багато відповідей на кшталт: писати unit-тести, наймати більше QA, використовувати статичні аналізатори або просто молитися, щоб все не зламалося в продакшні. Але при цьому часто забувають або взагалі не знають, що існує формальна верифікація. Що це таке? Це коли існує математичний доказ того, що програма працює правильно відповідно до формальної специфікації. В ідеальному світі це має робити unit-тести та QA, ... непотрібними, принаймні для тих частин системи, які формально верифіковані. Чи це реально так круто, як здається?

Щоб зрозуміти це, варто спочатку подивитися на ієрархію методів верифікації. В авіаційному стандарті DO-178C визначено рівні безпеки залежно від критичності коду:

  • Рівень Е (No Effect), ніяк не впливає на безпеку літака: тестування опціональне.
  • Рівень D (Minor), незначні незручності для екіпажу: базові unit-тести, code review.
  • Рівень C (Major), серйозні проблеми, але без загрози життю: 100% покриття коду, інтеграційні тести.
  • Рівень B (Hazardous), можливі травми пасажирів або екіпажу: статичний аналіз, property-based тестування, фаззінг.
  • Рівень A (Catastrophic), загроза життю або катастрофа: формальна верифікація, математичний доказ коректності.

Можна бачити, що на вершині піраміди стоїть формальна верифікація, найвищий рівень безпеки коду, який можна досягти! А чи варто використовувати її у ваших проєктах — вирішувати вам. Моя задача — тільки пояснити, як воно працює.

Є гарний ресурс Software Foundations, присвячений формальним методам верифікації програм. Там є розділ 5, присвячений саме формальній верифікації C-коду. Але там же й написано «перед читанням треба пропрацювати Volume 1 (та виконати всі вправи), потім Volume 2 (та виконати всі вправи)». Звичайно що не кожен пан має час та натхнення це робити. Та і сам текст це «відкрийте CoQ, виконайте команду, вона працює так-то, а ця команда не працює». Тому я вирішив просто взяти звідти перший приклад використання, щось на кшталт Getting Started, та переказати його зміст на рівні «взагалі по взагалям», не занурюючись у магію CoQ. Вважайте це розважальним контентом.

Що ж таке формальна верифікація та що за звір цей CoQ? CoQ це proof assistant, інструмент для перевірки математичних доказів. Уявіть собі дуже прискіпливого редактора, який перевіряє кожну кому вашої статті та не пропустить жодної логічної помилки. Тільки цей «редактор» — це комп’ютерна програма, яка знає все про логіку та математику. Формальна верифікація полягає у тому, що ми записуємо вимоги у вигляді математичних тверджень, а потім доводимо, що наш код відповідає цим вимогам. Але годі слів, переходимо до прикладу.

#include <stddef.h>

unsigned sumarray(unsigned a[], int n) {
  int i; unsigned s;
  i=0;
  s=0;
  while (i<n) {
    s+=a[i];
    i++;
  }
  return s;
}

unsigned four[4] = {1,2,3,4};

int main(void) {
  unsigned int s;
  s = sumarray(four,4);
  return (int)s;
}

Перед нами проста програма на мові програмування Сі. Приблизно друга програма у житті, яку пишуть новачки відразу після розв’язання квадратного рівняння. Функція знаходить суму елементів масиву. Дуже простий приклад, якраз те, що треба на початку. Але в цій функції є цикл, що для нас потім буде важливо.

Але чому саме обрана мова програмування Сі? Головним чином через її простоту. «Як так?» — запитають деякі! Адже Сі це дуже складна мова програмування! Саме тому у новачків більшу популярність має Python! Це так, складність буває різна. Сі простий як лопата. На Python простіше рити котлован для фундамента дому. Але ніхто не скаже, що конструкція екскаватора простіше за лопату.

Звісно що аналогія це спрощення. У світі програмування будь-яку лопату можна покращувати до нескінченності. До речі, CPython написаний на мові програмування Сі. Але мова йде більше про те, що з точки зору математики простота конструкції важливіше. Саме тому алгоритмічна нерозв’язність доводиться на мові програмування BrainFuck (машина Тюрінга), але спробуйте на ньому щось написати! Тому для формальної верифікації простота мови Сі це благо, а не перешкода.

Як працює верифікація? Зазвичай у нас є два світи. Ідеальний платонічний світ математики, де просто будувати докази. Та жорсткий брутальний світ програмування, де твій код падає зі страшною невизначеною поведінкою (UB, undefined behaviour). І у нас, як завжди у фантастичних творах, є портали між світами. Так, наприклад, типу unsigned int, який обмежений місткістю регістра процесора, відповідає математичний необмежений тип цілих чисел ℤ, який містить нескінченну кількість значень. Перевести unsigned int в ℤ досить легко — беремо відповідне число. А от назад... є варіанти, але за замовчуванням просто береться число по модулю. Тоді сумі двох цілих чисел буде відповідати сума по модулю, точнісінько як її виконує процесор. Цей портал працює ідеально: ми можемо спочатку додати числа, а потім перевести. Чи спочатку перевести, а потім скласти, результат це не змінить. Математики люблять малювати комутативні діаграми, це найпростіший приклад.

Комутативна діаграма

Тут намальовано два шляхи від лівого верхнього кута до правого нижнього. Перший шлях: спочатку додаємо, потім беремо по модулю. Другий шлях: спочатку беремо по модулю, потім додаємо. А по законах жанру, якщо два шляхи приводять в одну точку, то результат однаковий.

Масиву буде відповідати список. Типу int... буде відповідати той же самий тип ℤ. Але цього разу побудувати таку красиву комутативну діаграму у нас не вийде. Бо переповнення int відповідно до специфікації це UB. Тому в функції sumarray, якщо уявити що параметр a має тип int замість unsigned, то довести, що функція повертає суму елементів масиву можна буде лише у разі, коли n==0 або n==1. Якщо n>1, то... нам треба буде додати два числа, а це може бути переповнення, і все поламається.

З чого починається верифікація? Оскільки всі докази у нас будуть в CoQ, то нам треба отримати AST (Abstract Syntax Tree — абстрактне синтаксичне дерево нашої програми). Це робиться за допомогою команди clightgen:

clightgen -normalize sumarray.c

Так, в моєму випадку вона матюкнеться:

In file included from :466:
:16:9: warning: redefining builtin macro [-Wbuiltin-macro-redefined]
   16 | #define __STDC_NO_THREADS__ 1
      |         ^
1 warning generated.

Але все ж таки створить файл sumarray.v. Це CoQ-файл, який містить математичне представлення нашого коду. Трохи зупинимося на описах статичної змінної four та функції main:

Definition v_four := {|
  gvar_info := (tarray tuint 4);
  gvar_init := (Init_int32 (Int.repr 1) :: Init_int32 (Int.repr 2) ::
                Init_int32 (Int.repr 3) :: Init_int32 (Int.repr 4) :: nil);
  gvar_readonly := false;
  gvar_volatile := false
|}.

Definition f_main := {|
  fn_return := tint;
  fn_callconv := cc_default;
  fn_params := nil;
  fn_vars := nil;
  fn_temps := ((_s, tuint) :: (_t'1, tuint) :: nil);
  fn_body :=
(Ssequence
  (Ssequence
    (Ssequence
      (Scall (Some _t'1)
        (Evar _sumarray (Tfunction (Tcons (tptr tuint) (Tcons tint Tnil))
                          tuint cc_default))
        ((Evar _four (tarray tuint 4)) :: (Econst_int (Int.repr 4) tint) ::
         nil))
      (Sset _s (Etempvar _t'1 tuint)))
    (Sreturn (Some (Ecast (Etempvar _s tuint) tint))))
  (Sreturn (Some (Econst_int (Int.repr 0) tint))))
|}.

Префікси v_ та f_ позначають змінні та функції відповідно. Видно LISP-подібне AST з функціями на кшталт Ssequence, Scall, Sreturn. Купа атрибутів як gvar_readonly, fn_callconv береться безпосередньо з коду та стандарту C.

В принципі це так, для загальної інформації, звідки CoQ про це знає. Збілдити цей файл можна командою:

coqc -Q . VC sumarray.v

Тут -Q . VC означає, що поточна директорія буде доступна як простір імен VC в CoQ. В результаті буде згенеровано багато файлів sumarray з різними розширеннями, що дозволить нам потім імпортувати цей файл за допомогою команди Require Import VC.sumarray.

Далі в наших планах створити файл Verif_sumarray.v в якому будуть міститися специфікації та докази коректності. Там ми побудуємо математичну функцію sum_Z, яка буде знаходити суму елементів списку цілих чисел, та доведемо, що вона еквівалентна нашій C функції sumarray.

Тут відразу виникає перше запитання. Так, припустимо, ми довели, що функції еквівалентні. Але що буде, якщо ми припустилися помилок в двох реалізаціях? Так, це гарне запитання, ми можемо помилитися у формальній специфікації. Але в математичному світі ми зможемо довести більше фактів про sum_Z. Наприклад, що виклик функції sum_Z для списку з одного елементу поверне цей елемент. Або що виклик sum_Z для конкатенації списків дорівнює сумі викликів sum_Z для кожного списку окремо.

В наступному фрагменті коду є визначення функції sum_Z через операцію згортання (fold), а також докази цих фактів:

Definition sum_Z : list Z -> Z := fold_right Z.add 0.

Lemma sum_Z_one_elem : forall x, sum_Z [x] = x.
Proof.
  intro. simpl. lia.
Qed.

Lemma sum_Z_app:
  forall a b, sum_Z (a++b) =  sum_Z a + sum_Z b.
Proof.
  intros. induction a; simpl; lia.
Qed.

Тепер ці факти формально доведено (можете не звертати уваги на магію CoQ). І тепер у нас не дуже багато варіантів помилитися таким чином, щоб функція sum_Z задовільняла ці умови, та при цьому робила щось відмінне від суми. Але факт є фактом, помилка у специфікації може звести нанівець усі наші зусилля. Втішає лише факт, що помилитися у специфікації складніше, а ще й так, щоб ця помилка точно відповідала помилці в коді, то це або диверсія, або нереальний збіг обставин.

Тепер ми готові дати формальну специфікацію функції sumarray, яка буде виглядати наступним чином:

Definition sumarray_spec : ident * funspec :=
DECLARE _sumarray
 WITH a: val, sh : share, contents : list Z, size: Z
 PRE [ tptr tuint, tint ]
  PROP  (readable_share sh; 0 <= size <= Int.max_signed;
         Forall (fun x => 0 <= x <= Int.max_unsigned) contents)
  PARAMS (a; Vint (Int.repr size))
  SEP   (data_at sh (tarray tuint size) (map Vint (map Int.repr contents)) a)
 POST [ tuint ]
  PROP () RETURN (Vint (Int.repr (sum_Z contents)))
  SEP (data_at sh (tarray tuint size) (map Vint (map Int.repr contents)) a).

Виглядає моторошно... Але якщо пропустити магію CoQ, то тут у секції WITH можна побачити сутності ідеального математичного світу, які будуть відповідати змінним sumarray: a це показчик на масив, contents це сам масив, вміст пам’яті з адреси a довжиною n * sizeof(unsigned) байт, size це змінна n, а sh... забийте, це права доступу до пам’яті, на яку посилається a.

Наступна секція PRE. Вона містить умови, які накладаються на аргументи функції, щоб виклик пройшов коректно. Якщо їх перекласти людською мовою, то:

  • readable_share sh — пам’ять, на яку посилається a має бути доступною для читання,
  • 0 <= size <= Int.max_signed — розмір масиву не може бути від’ємним,
  • Forall (fun x => 0 <= x <= Int.max_unsigned) contents — жодних обмежень на елементи масиву a.

Наступна секція POST. Вона містить твердження, які автоматично будуть виконані після завершення функції. Звісно, при умові, що у момент виклику були виконані всі умови PRE. Якщо їх перекласти людською мовою, то:

  • RETURN (Vint (Int.repr (sum_Z contents))) — функція поверне значення sum_Z contents, суму всіх елементів масиву,
  • SEP (data_at sh (tarray tuint size) (map Vint (map Int.repr contents)) a) — масив залишиться незмінним після виконання функції.

В принципі, умови виглядають досить логічно, нам лишилося лише довести це. От як це робиться:

Lemma body_sumarray: semax_body Vprog Gprog f_sumarray sumarray_spec.
Proof.
  start_function.
  forward. (* i = 0; *)
  forward. (* s = 0; *)
  forward_while
   (EX i: Z,
     PROP  (0 <= i <= size)
     LOCAL (temp _a a;
            temp _i (Vint (Int.repr i));
            temp _n (Vint (Int.repr size));
            temp _s (Vint (Int.repr (sum_Z (sublist 0 i contents)))))
     SEP   (data_at sh (tarray tuint size) (map Vint (map Int.repr contents)) a)).

  - (* Check invariant before *)
    Exists 0. entailer!.

  - (* i < n *)
    entailer!.

  - (* loop body *)
    assert_PROP (Zlength contents = size). {
      entailer!.
      do 2 rewrite Zlength_map. reflexivity.
    }
    forward. (* t' = a[i] *)
    forward. (* s = s + t' *)
    forward. (* i = i + 1 *)
    Exists (i+1).
    entailer!. f_equal. f_equal.
    rewrite (sublist_split 0 i (i+1)) by lia.
    rewrite sum_Z_app. rewrite (sublist_one i) by lia.
    simpl. lia.

  - (* Continue execution after loop *)
    forward.  (* return s; *)
    entailer!.
    autorewrite with sublist in *|-.
    autorewrite with sublist.
    reflexivity.
Qed.

Ознайомимося трохи зі структурою доказу. Це дуже нагадує відлагодження. Тільки, коли ми виконуємо команду next в gdb (або вибираємо Debug -> Step в меню IDE), то ми виконуємо команду при конкретних значеннях змінних. А коли ми виконуємо тактику forward, то переходимо на наступну команду для всіх можливих значень змінних. Коли ми виконаємо forward на операторі i=0, то в нашу базу фактів буде додано факт, що i==0. Те ж саме з s. Також нескладно за допомогою forward виконати if (треба буде послідовно пройтися по гілках). Але складнощі починаються з циклом: ми не можемо його пройти послідовно, бо в різних викликах функції може бути різна кількість ітерацій.

Але вихід є! Він називається «інваріант циклу». Це таке твердження, яке залишається істинним після кожної ітерації. Погана новина полягає у тому, що ніхто не допоможе з тим, який інваріант циклу буде корисним. В даному разі інваріант доволі очевидний і складається з двох тверджень:
(1) 0 <= i <= size — лічильник знаходиться в межах масиву,
(2) сума чисел від 0 до i в масиві a дорівнює s.

Або мовою CoQ: PROP (0 <= i <= size) та temp _s (Vint (Int.repr (sum_Z (sublist 0 i contents)))). Там sum_Z (sublist 0 i contents) це і є наш функціональний виклик функції sum_Z, перед яким йде трохи магії.

Тепер нам треба довести, що (1) інваріант виконується на початку циклу; (2) умова циклу виконується без UB; (3) інваріант циклу зберігається при виконанні циклу з додатковою умовою i<n що ми не вийшли з циклу — тут треба пройти цикл один раз в нашому «відлагоджувачі». І все, можна буде додати до списку фактів інваріант та умову виходу з циклу, та продовжити доказ. В термінології CoQ це означає, що нам треба довести чотири цілі: (1)-(3) та ціль (4) продовження після циклу. Початок доказу кожної нової цілі починається з символу мінус -.

Перша ціль: інваріант виконується на початку. Доказ тривіальний, бо ми вже знаємо, що i==0 та s==0, а сума нуля елементів це нуль. Тільки трохи магії, яка синхронізує світи.

Друга ціль: умова циклу виконується без UB. Теж тривіальна — порівняння двох int визначено для будь-яких значень. entailer!. тут означає спростити усе що тільки можливо, після чого результат буде очевидний.

Третя ціль: інваріант зберігається. Тут ми виконуємо покроково тіло циклу. Зверніть увагу, що два оператори s+=a[i]; i++; замінилися трьома діями: t'=a[i]; s=s+t'; i=i+1; І потім знову трохи магії — доводимо, що сума елементів від 0 до i+1 дорівнює сумі елементів від 0 до i плюс елемент з індексом i. Для цього ми використовуємо лему sum_Z_app, яку доводили на початку.

Тепер остання ціль, виконуємо return s; через forward, після чого з трьох фактів: i >= n (умова виходу з циклу), 0 <= i <= size (з інваріанту) та size = n (з умов PRE) робимо висновок, що i = n, отже s дорівнює сумі всіх елементів масиву.

Поздоровляю, ми верифікували нашу першу функцію!

Зробимо невеличкий огляд. Доказ, в принципі, був досить прямолінійний. Єдина проблема була знайти інваріант. І то для даного прикладу він був досить очевидним, але в інших більш практичних випадках це може бути справжнім викликом.

Якщо візьмемо у якості більш неочевидного прикладу відому задачу реверсу списку:

struct node* reverse(struct node* list) {
    struct node* result = NULL;
    struct node* remaining = list;

    while (remaining != NULL) {
        struct node* current = remaining;
        remaining = remaining->next;
        current->next = result;
        result = current;
    }

    return result;
}

То тут інваріантом буде reverse(result) ++ remaining == list. По виходу з циклу remaining буде пустим, тому отримаємо reverse(result) == list. Бачимо, що формула досить неочевидна, і автоматично не може бути отримана, знаходити інваріант це творча робота розробника.

Тут цікаво провести паралелі з іншим методом верифікації, який реалізований у SPARK (Ada). Подивимося на приклад:

with Loop_Types; use Loop_Types;

procedure Zero_Arr (A : out Arr_T) with
    SPARK_Mode,
    Post => (for all J in A'Range => A(J) = 0)
is
begin
   for J in A'Range loop
      A(J) := 0;
      pragma Loop_Invariant (for all K in A'First .. J => A(K) = 0);
   end loop;
end Zero_Arr;

Тут ми бачимо верифіковане обнулення масиву. Виглядає трохи людяніше: pre та post умови та інваріант циклу ми бачимо в коді, а не в окремому файлі. Принцип аналогічний: ми пишемо інваріант циклу, а далі вже сам SPARK пробує різні кроки для того, щоб довести післяумову. Єдина відмінність полягає у тому, що якщо інваріанти не допомогли, то нічого вдіяти не можна. А у разі CoQ ми можемо (насправді мусимо) довести будь-яку лему вручну, якщо її взагалі можна математично довести. Ну і якщо вистачить скілу та терпіння.

Таким чином, що нам дає CoQ? По-перше, там формально описана семантика мови програмування Сі. Оскільки Сі проста як лопата, то це задача хоча й складна, але, як бачимо, підйомна. Про інші мови тут думати не хочу. По-друге, там реалізовані різні тактики, які дозволяють нам ходити по дереву AST та виконують трохи бойлерплейту, хоча хотілося би більше.

Можливо, комусь це може здатися дитячою забавкою. Дійсно, довели елементарщину! Але починати треба з елементарщини. Якщо когось зацікавлять більш серйозні речі, то пропоную подивитися на каталог всіх прикладів VST. Там є верифікація збирача сміття з поколіннями (generational garbage collector) для програм з графами, паралельна система повідомлень (concurrent messaging system) та багато іншого.

Ну все, я думаю, що достатньо вас розважив. Тепер ви маєте мати трохи кращу уяву про верифікацію. Кому цікаво йти далі, то даю посилання:

Software Foundations — фундаментальний курс з формальних методів та верифікації програм. Складається з кількох томів, від основ логіки до практичних застосувань. Volume 5: Verifiable C присвячений саме VST та містить набагато більше прикладів і деталей, ніж у нашій статті. Там також можна знайти, як доводити операції зі списками, слідкувати за malloc/free — те, що Ada+SPARK робить погано.

Офіційний підручник VST з детальними поясненнями та прикладами.

I can’t believe that I can prove that it can sort — стаття AdaCore про верифікацію алгоритмів сортування на SPARK.

Приклади можна знайти на github.

Сподобалась стаття автора? Підписуйтесь на його акаунт вгорі сторінки, щоб отримувати сповіщення про нові публікації на пошту.

👍ПодобаєтьсяСподобалось9
До обраногоВ обраному4
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

оргазмую
буду ще тиждень розбирати

доу торт

з комутативною діаграмою щось не те. по-перше, сума і модуль на цілих числах не комутують: |(-1) + (+1)| = 0, |-1|+|+1| = 2. по-друге, зазвичай у вузлах стоять множини, тобто мало би бути ℤ×ℤ ну і там ℤ₊×ℤ₊ чи ще щось таке. як приклад, от перше що під руки попалося (Spanier, Algebraic Topology):
res.cloudinary.com/...​/cmzwq4rrfgezny08mp9x.png

по-перше, сума і модуль на цілих числах не комутують

У мене там немає модуля ніде. Там Int.repr в Coq це перевод математичного цілого числа в unsigned int, де −1 переведеться в UINT_MAX, дві конкретні операції, які комутують.

по-друге, зазвичай у вузлах стоять множини, тобто мало би бути ℤ×ℤ

Це просто питання як це відобразити. Так, можна записати ℤ×ℤ зверху зліва, та uint×uint знизу зліва. Але я вирішив записати a, b ∈ ℤ та a, b ∈ uint, що те ж можливо та більше відповідає синтаксису Coq. Це трохи тавтологічно, більш детальніше розписано для тих, хто не має досвіду. Напевно a’, b’ ∈ uint було б краще. У вашому прикладі це більш скорочений просунутий варіант, я так це бачу.

У мене там немає модуля ніде

цитую текст безпосередньо під діаграмою:

Тут намальовано два шляхи від лівого верхнього кута до правого нижнього. Перший шлях: спочатку додаємо, потім беремо по модулю. Другий шлях: спочатку беремо по модулю, потім додаємо. А по законах жанру, якщо два шляхи приводять в одну точку, то результат однаковий.

звідси я зробив висновок, що береться модуль числа. так, можна було погуглити що таке `Int.repr`, але тут, вибачте, всю статтю треба гуглити, бо Coq я не знаю. хіба що стаття для тих, хто вже і так знає Coq, тоді мій косяк.

можна записати ℤ×ℤ зверху зліва, та uint×uint знизу зліва

краще замість uint×uint щось типу ℤ/nℤ×ℤ/nℤ, або більш типово для комп’ютерних наук ℤₙ×ℤₙ, ну і десь раніше означити n. так буде видно, що йдеться про остачі від ділення.

це більш скорочений просунутий варіант, я так це бачу

швидше різниця між описом функції та конкретизацією її дії на одному елементі, але в такому випадку використовують трохи іншу стрілочку типу ↦ . так, наприклад, коли описують функцію загалом, то пишуть f: ℤ×ℤ → ℤ, а коли конкретизують її дію для одного елемента множини, то типовий запис буде f: (x,y) ↦ x+y. конкретно для цієї діаграми схоже, що Int.repr це canonical projection (не знаю перекладу) і типово позначається π. Тоді π: ℤ → ℤₙ, конкретно π: z ↦ z mod n. а зліва тоді краще π×π: ℤ×ℤ → ℤₙ×ℤₙ, конкретизація π×π: (x,y) ↦ (x mod n, y mod n).

потім беремо по модулю

Це ж порівняння по модулю, так теж кажуть складаємо по модулю:
(a + b) mod n = ((a mod n) + (b mod n)) mod n.
Обидва шляхи дають однаковий результат, остачу від ділення на n.

краще замість uint×uint щось типу ℤ/nℤ×ℤ/nℤ,

Це і далі придирка до нотації. Моя аудиторія все ж таки програмісти, тому я хочу показати, що відбувається з конкретними елементами a, b, а не які стрілочки можна малювати між абстрактними множинами. Також мені важливо показати пряме відношення до сішного коду, тому uint замість ℤₙ. Одне діло це стаття на arxiv.org де читачи собаку зʼїли на таких діаграмах. Інше діло dou, де я хочу показати на прикладі ефективність комутативних діаграм, їх суть, зробити ідею доступною, а не показати академічну строгість запису.

ну не знаю, я сторонник підходу, щоб відразу вчити правильно. може якісь кастомні позначення і дадуть виграш на початку, але цей початковий виграш швидко вичерпається, бо всі інші ресурси будуть використовувати більш традиційну систему і доведеться переучуватися. краще вже пройти зайву милю і додати пояснень, але притримуватися традицій. як на мене, так читачу буде більше користі.

Можливо я трохи олдскульний. Я пам’ятаю математичний жарт «будь-яку річ можна назвати трамваєм, якщо про це домовитися». Що відображало той факт, що кожен використовував ті визначення та позначення, які вважав доречними. В радянській шкільній традиції натуральні числа починалися з одиниці. У французькій (Бурбакі) нуль це натуральне число. Тому перед тим як починати читати, ти дивишся на позначення, визначення, тому що жодних традицій немає. Кон’юнкція може позначатися ∧, &, ·. Імплікація як →, ⇒, ⊃. Кнут строго розрізняє O та Θ, тоді як багато хто використовує O для всього. Визначення графів, біноміальні коефіцієнти, тощо...

Я не грамар-наці, я скоріше за еволюцію. Але, як на мене, корисніше не звикати до догми, а завжди бути готовим до того, що у автора будуть свої позначення.

Тепер ще має бути, формальна валідація — тобто якось треба математично довести, що програмне забеспечення виконує саме той алгоритм, який був у вимогах до програмного продукту.

А що ви розумієте під вимогами? Якщо їх можна виразити як математичні твердження, то формальна верифікація саме це і робить, вона математично доводить, що програма відповідає специфікації. Якщо вимоги не можна виразити мовою математики, то я просто не згоден називати це вимогами, це побажанки, балабольство, неформальне очікування, тощо.

Справа в тому, що програмне забеспечення сворюється для людей — не для компьютерів, самі компьютери теж створені для людей. Процесс створення програмного забеспечення — це процесс поступової формалізації вимог, від аюсолютно не формальних — до програмного коду, тобто абсолютної формалізації (формалізм — філософсько математичний).
Військові і науково-інженерні та різні державні організації в своєму SDLC і справді здатні робити детальні фази аналізів вимог, дослідів і науково кострукторских робіт — щоби надати вам чіткі спеціфікації, щодо вимог до ПЗ. Та в більшості випадків — це було визнано утопічним. Подробиці у Мартіна і т.д. Бо я переписую у форум книжки.

Програмний код це формальна реалізація формальних вимог. Тобто розробник формалізував (часто у себе в мозку) бажання замовника та зробив формальну реалізацію цих вимог у коді. Проблема в тому, що ця реалізація може містити баги: невідповідність між формальними вимогами та формальною реалізацією. Наприклад, коли сервер крешиться, то це не через те, що розробник так зрозумів побажання замовника. Це тому, що він помилково їх реалізував. Формальна верифікація дозволяє уникнути цього, звісно не безкоштовно.
Тому це просто різні речі. Проблема № 1: зрозуміти вимоги. Проблема № 2: коректно реалізувати те що зрозумілося. Я уважно не читав Мартіна, тільки продивився по діагоналі, не буду читати і нікому не раджу. Він для мене не авторитет, особливо небезпечний для новачків, які сильно ведуться на надування щік. Як на мене це дуже догматичні безапеляціонні поради, які дуже сильно прив’язані до стеку (Java) та домену (enterprise). Я так зрозумів, що він вважає проблему № 2 неактуальною, яку можна забити. Ок, його право, я з цим не згоден. Протягом своєї кар’єри я майже не спілкувався зі замовниками, отримував задачі від технічних спеціалістів, і проблема № 1 майже ніколи не була актуальною: завжди зрозуміло що треба, головне питання як саме...

Проблема номер один, яка є причиною провалу програмних проектів — неправильне розуміння вимог, тобто помилка в самих вимогах. Це одна з головних причин не правильного планування. За різними дослідами — The Stadish Gropup та PMI.
І частіше за усе вона виникає тому, що просто не достатньо інформації щоби вірно поставити задачу, її банально не можуть вірно зібрати.
З мого боку, чітко скажу — що досвідчений розробник часто відрізняється в першу чергу добрим знанням предметної галузі задачі автоматизації (бізнес домену). З технічним стеком — справді ШІ, стек оверфлоу із пошувиком та документація в поміч.
А от експертні системи типу іграшки як Akinator — які за методом Сократа вміють ставити людині правильні питання — це вже може бути Sky Net.

По-перше, той факт, що проблема № 1 важлива, ніяк не заперечує той факт, що проблема № 2 теж важлива. Це не взаємовиключні речі. Більше того, в різних доменах їхня відносна важливість різна.
По-друге, треба ставитися до результатів таких досліджень (точніше, опитувань) досить критично. І це я мʼяко кажу :-) Це не подвійні сліпі експерименти, а саме те, що люди хочуть бачити і говорити. Хто ж скаже «ми тупі, не змогли правильно реалізувати»? Набагато зручніше перевести провину на замовника: «вимоги були нечіткі». Я дуже часто бачив, як у проєктах з жахливим кодом намагалися спихнути провину саме на замовника та нібито погані вимоги, хоча насправді проблема була в компетенції розробників або в технічному боргу, який накопичився через погані архітектурні рішення. А потім, коли замовник йшов, усі радісно видихали «нарешті»... Як дослідження виміряють саме саботаж, технічний борг, погану розробку?

А що ви розумієте під вимогами? Якщо їх можна виразити як математичні твердження

А хто буде виражати вимоги в математичні твердження? Бізнес, який ставить вимоги, зазвичай не може їх виразити в математиці. А якщо це не відбувається відразу, а на наступній ланці — BA, програміст, то це вже фактор miss-understanding’a. Вони можуть написати мат.вираз і кинути на апрув бізнесу, тут він вже напише — ну я хз що це, робіть я написано. Банальний приклад, бізнес пише — якщо в формі обрали оплата онлайн, то з’являється на вибір способи оплати — google/Apple pay, Visa/Master card, Swift, etc. На це бізнесу кидають якусь формулу і питають все вірно? Думаю зроблять круглі очі. Так, це дуже проста задача, не впевнений що тут треба така формалізація. Але якщо будувати такий flow-розробки, то мабуть і тут треба. Це я щоб взагалі зрозуміти як буде відбуватись апрув, що формула відповідає вимогам, навіть ще до початку реалізації.

Бізнес буває різний. Так, якщо мова йде про те, щоб написати «зручний інтерфейс», то це неможливо виразити математично. Але якщо мова про те, щоб після оновлення прошивки девайс (умовний марсоход) не ставав цеглиною, то це цілком можна виразити формально.
Коли до вас прийде Airbus, вони самі розумітимуться на верифікації і формулюватимуть вимоги відповідно. Якщо треба зробити сайт-візитку, то напевно ні.
Так само з компіляторами: більшість людей компілює у clang, gcc чи vc, і це нормально. Але є бізнес, для якого це неприйнятно, бо потрібні гарантії того, що компілятор згенерував код коректно і в ньому немає багів.
Тому є ніші, де формальна верифікація необхідна (авіація, космос, медичне обладнання, критична інфраструктура). А є ніші, де можна обійтися й без неї, нічого страшного не трапиться. Є ніші, де було б непогано (криптографія, бібліотеки, sbin утиліти, тощо). Виходить нова версія Java с новим GC. Скільки часу треба чекати, щоб переконатися, що в ньому немає багів на кшталт JDK-8225716 (звільнення памʼяті, яка ще використовується)?

то мабуть і тут треба

Ще раз, відповідно до авіаційного стандарту, верифікація потрібна на рівні

A (Catastrophic), загроза життю або катастрофа: формальна верифікація, математичний доказ коректності.

Ще раз, я не хочу сказати, що уся розробка ПЗ ведеться неправильно, і треба всім переключатися та писати виключно верифікований код. Я просто хочу сказати, що є і такий інструмент, що там є певні досягнення, і це працює так, більше для загального розвитку.

Поінт зрозумів, що це не для кожної програми потрібно. І згоден з цим, бо інакше треба повністю перебудовувати всю систему освіти в software engineer’ингу. І у викладанні тих же алгоритмів починати не зі складності (big-O, та інші О), а саме з мат.апарату доведення корректності.

О це б добавити в статтю, а то дуже складно навіщо це для новачка в цій темі — dou.ua/...​го страшного не трапиться.

І, доречі, цікаві філософські думки в цьому треді: трохи про своє і про спільне — і всі праві по-своєму. І ще радує, що гарною технічною українською. Хай квітне українське доу :)

Частково це є:

Але факт є фактом, помилка у специфікації може звести нанівець усі наші зусилля. Втішає лише факт, що помилитися у специфікації складніше, а ще й так, щоб ця помилка точно відповідала помилці в коді, то це або диверсія, або нереальний збіг обставин.

Тобто, метод заключається в математичній перевірці коду, правильно?

Це метод запропонований ще Едгером Дейстерою. В книзі Роберта Мартіна (дядька Боба) Clean Architecture ви знайдете причину чому в індустрії цей метод — провалився і використовується науково-доказовий підхід, тобто тестування і чому потрібні QA спеціалісти. Деякі компанії та клієнти категорично не хотіли визнавати необхідність витрачати грощі на QA.
А математично доказовий метод, це Вічний Двинун, з яким носяться Хаскелісти з університетів здебільшого, по різним конференціям. Що цікаво часто тролять C++ ників.

Носяться Хаскелісти, через 5 років воно з’являється в Скалі, а через 10 в Джаві) Як і з тим само property-based тестуванням, про яке згадується на рівні В.
QA інженерний підхід, формальна верифікація академічна. Якщо є час і гроші, краще і те і інше

Ну... все ж таки, як на мене, між property-based тестуванням та верифікацією прірва. Сама ідея property-based тестування (генерувати приклади та перевіряти інваріанти) лежить на поверхні. Один з перших unit-тестів, що я написав у житті на DUnit (Delphi), це був тест сортування: генеруємо випадковий масив, сортуємо, та перевіряємо що він відсортований. Ось тобі фаззінг та property-based в одному флаконі, звісно що тоді я не знав навіть таких термінів! Щоб це робити, тобі нічого не треба взагалі. Мова йде максимум про бібліотеки, які це спростять, але що там радикально спростиш?

А от з верифікацією складніше, на колінках не зробиш. Треба proof assistant, а це вже монстр складніше за мови програмування, принаймні зовсім інший звір. Треба перевести стандарт мови у математичні твердження.

Це метод запропонований ще Едгером Дейстерою

Казати, що цей метод запропонував Дейкстра, це як казати, що Жюль Верн винайшов підводний човен. Дейкстра міг лише мріяти про це, бо на той час просто не було потрібних інструментів. Так, можна було зробити математичний доказ на папері, але хто перевірить, що людина не припустилася помилок?
Реальні інструменти для формальної верифікації з’явилися значно пізніше: Calculus of Constructions (CoC) був теоретично розроблений Thierry Coquand у 1984-1985 роках, Coq (1989), Isabelle (1986), Lean 4 — це взагалі 2021. Звісно, ще потрібен був певний час, щоб обкатати. Наприклад, Verified Software Toolchain (VST) для верифікації C програм, який використовується у статті, почав розроблятися у 2010, перша стабільна версія вийшла 2015, далі час на розвиток, розширення можливостей для практики, бо перші версії зазвичай сирі, незручні, вимагають багато ручної роботи.
Більшість верифікованого софту, про який я згадую, це останні роки.

з яким носяться Хаскелісти з університетів здебільшого

А до чого тут хаскелісти? Haskell це звичайна мова програмування з сильною системою типів, але без формальних доказів коректності. Тут мова більше про proof assistants, та їх застосуння для перевірки сішного коду.

Якщо зможете показати реально практичний досвід при створенні ПЗ, на примірі того же браузер компонента там і т.п. то можливо — ми просто дійшли до стану коли можлива реалізація. Бо зараз і справді багато чого запрпонованого десятиліття тому практично реалізується. Те що хотів зробити Дейкстера в компіляторі Algol 60, з рештою реалізовано в RUST. Сем Альтман анонсував, що нова версія GPT пройшла тест Тюрінга запропонований ще в 1950 році тощо.
Доки особисто я топлю за британо-японський менеджмент із : ітераціями в два тижні, описання вимог у виді користувацьких історій із критеріями оцінки якості через які робиться верифікація, та тестуванням із описами знайдених дефектів (багів на слензі від тестування ще ENIAC) у виді задокументованих шляхів до відтворення, очікуваного результату і отримуваного результату тобто результати валідації.
Роками перевірено, це як працююча схема яка надає відтворюємі результати.
P.S. Якщо буде реально позитивні результати — ну тоді так, можемо погоджуватись, що більшість ІТ професій доживають свої останні дні — як колись професія пильщика або молотобійця яких замінили пилорама з приводом від вітряка, та ковальний молот із приводом від водяного колеса. Бо софт стане можливо розробляти повністю за допомогою ШІ, виключно бізнес аналітиками і ІТ бізнесменами. Далі нас вже чекає або сценарій Terminator, чи швидше мамонтів, що вимерли від нестачі кормової бази і охоти різних підвидів людей хомо.

Так, по суті правильно. Ми робимо специфікацію (набір математичних тверджень відносно коду), отримуємо математичний доказ того, що код задовольняє специфікації, та формальну перевірку того, що цей математичний доказ не містить помилок.

Щось після цієї статті мені здається що я гуманітарій

Коли мені щось незрозуміло, я йду по тексту речення за реченням. Метод Фейнмана, доки не розумієш речення, не рухаєшся далі. До речі зараз є LLM щоб обсудити.

І де виникло перше нерозуміння?

Ну ок, речення за реченням. А пруфи — теж крок за кроком? Щось мені підказує що навіть фраза «не звертати увагу на магію Coq» викличе питання. Бо буде незрозуміло де там магія, яку треба пропустити.
В доказі двох маленьких лемм про sum_Z — що мається на увазі під магією на яку потрібно не звертати уваги? Тактика lia? Чи весь доказ замінити на Admitted?
В статті ж навіть ніде не написано що таке Defintion, які такі «цілі» нам потрібно доводити. Да навіть те що в доказі ті цілі потрібно шукати там де символ «-» - навіть про це ніде не написано.

Чи це така замануха — «а ось в процесі розбору статті по реченням — ви як раз прочитаєте Volume 1 та Volume 2 з Software Foundations» :)

А пруфи — теж крок за кроком?

Я розумію, що я можу щось пропустити, бо деякі речі ти сприймаєш само собою зрозумілими, а тут виявляється, що ні. Тому і є обговорення.

Звісно що пруфи без досвіду в Coq зрозуміти неможливо, навіть з досвідом треба йти покроково, дивитися на поточний стан, змінні і т. п. Але ідея була в тому, що

 Definition sum_Z : list Z -> Z := fold_right Z.add 0.
Це було описано як
В наступному фрагменті коду є визначення функції sum_Z через операцію згортання (fold)

І в принципі, при певному досвіді у функціональному програмуванні (fold або reduce) можна прочитати це визначення. Можливо дійсно треба додати пару слів про ML синтаксис, що fold це аналог reduce, ... Це як раз і цікаво, що треба додатково пояснити.

Далі:

Lemma sum_Z_one_elem : forall x, sum_Z [x] = x.
виклик функції sum_Z для списку з одного елементу поверне цей елемент

Це теж можна розібрати, воно так і майже і читається, для будь якого елементу x справедлива рівність.

А от якщо брати доказ:

Proof.
  intro. simpl. lia.
Qed.

то звісно якщо ти ніколи не бавив Coq, то зрозуміти що робить intro, simpl та lia складно, це магія.

Щось мені підказує що навіть фраза «не звертати увагу на магію Coq» викличе питання.

Але навіть при читанні математичного тексту, коли ти бачиш теорему, то при першому читанні ти можеш просто скіпнути доказ, типу ок, поки я тобі довіряю, подивимося до чого це призведе.

Ок, сума списку з одного елементу дорівнює цьому елементу. Факт достатньо інтуїтивний, і на цьому етапі треба розуміти, що його можна виразити у Coq (в принципі можна здогадатися як), та довести в Coq (тут магія intro, simpl, lia), але певні дії треба виконати навіть для такого простого факту.

Чи весь доказ замінити на Admitted?

Про Admitted я нічого не писав :-) І тут мені важливо було показати, що доказ, хоча й магічний та незрозумілий, існує. Admitted це просто пропускаємо доказ, колись допишемо.

Чи це така замануха — «а ось в процесі розбору статті по реченням — ви як раз прочитаєте Volume 1 та Volume 2 з Software Foundations»

Ще раз, тут важливо зрозуміти, що доказ існує. Якщо цікаво як, то так, треба працювати з Volume 1, це як на мене краще що є, усе інше дуже сильно математичне. Volume 2 це формалізація логіки Хоара, я не думаю, що це тут критично для розуміння. А якщо не цікаво, то просто робимо висновок (доказ існує) та рухаємося далі.

В статті ж навіть ніде не написано що таке Defintion, які такі «цілі» нам потрібно доводити.

Мені здається, що тут можна здогадатися по синтаксису.

Да навіть те що в доказі ті цілі потрібно шукати там де символ «-» - навіть про це ніде не написано.

Тут

Тепер нам треба довести, що (1) інваріант виконується на початку циклу; (2) умова циклу виконується без UB; (3) інваріант циклу зберігається при виконанні циклу з додатковою умовою i < n що ми не вийшли з циклу — тут треба пройти цикл один раз в нашому «відлагоджувачі». І все, можна буде додати до списку фактів інваріант та умову виходу з циклу, та продовжити доказ. В термінології CoQ це означає, що нам треба довести чотири цілі: (1)-(3) та ціль (4) продовження після циклу. Початок доказу кожної нової цілі починається з символу мінус -.

Можливо тут треба покращити? Мінуси поруч з коментарями

 - (* Check invariant before *)

Напевно можна сопоставити з (1) інваріант виконується на початку циклу;

Цей тролинг старшмй за нас із вами. Конкретно мене десь років на 25. І чомусь переодично вискокує, хоча усі описали — що це вічний двигун в ІТ.
На ділі — чим більше не зрозуміла концепція і абстракція, тим більша верогідність, що це повна фігня. Саме тому теорія верогідностей чи Велика Торема Ферма, в край складні речі — мають дуже прості вводні і пояснення.

Ну так можна почати як раз з Logical foundations (це Volume 1) — там як раз все доволі просто починається.
В мене, власне, тому і виникли питання до статті. Розказати щось з серердини — мабуть ок, типу «куди ми прийдемо в кінці кінців» (мотивація таки пройти всі частини). Але трошки б більше «на пальцях» — було б практичніше.

Звісно що я про це думав, але... Logical foundations це по суті найкращий практичний getting started для Coq саме для розробників. Бо скільки я не дивився та намагався читати інші книги, там вони захоплюються математикою. Якщо брати logical foundations, то... перевершити важко. Перекладати напевно немає сенсу, і в будь якому разі треба бачити контекст, тому треба ставити opam, coq, IDE. Ну я користуюся vim, але не кожному підійде. В принципі, запускай Claude Code чи Gemini, читай, обговорюй. Попроси перекласти, якщо треба. Тут статті не треба. Але скільки будуть це робити? А оглядово це треба після кожної тактики писати що змінилося, напевно можна, але питання навіщо. Можливо зайде тут більше відео, стріми :-)

Але є ідея написати статтю (чи знову відео) «Ізоморфізм Карі-Говарда», де побудувати аріфметику Пеано (основи) паралельно в Coq та Agda, щоб була можливість порівняти. Можливо Lean 4, я його не мацав, буде привід. Можливо стареньку Ізабельку для контрасту. Можливо Idris2, це більш практичний інструмент як на мене. Але стаття це не пропрацювання.

Так, із ідей ще «Математика = шахи + порно», де дати інтро до формальної (і не тільки) математики ілюстративно через аналогії.

Є ідея написати статтю про ADT, де трохи більше зупинится, як вона комутує з натуральними числами, та у мочу перевага таких діаграм: можемо переносити концепції з одного домена в інший.

Тому вирішив так, просто, щоб люди узнали про формальну верифікацію, та отримали хоч якесь уявлення. А викладачі щоб додали Logical Foundation в курси, як на мене не завадить, уж точно в порівнянні з іншими предметами.

Підписатись на коментарі