Погружение трансцендентального сознания в Coq, MathComp и MathComp-Analysis.
Глава 1. Краткое представление о Петушаре, Мелкомасштабном отражении и Математических компонентах
Цель этой главы — дать представление о некоторых теорем-пруверах, построенных на основе теории зависимых типов.
1.1 Немного истории
Феномен появления такой штуки как теорем-прувер является одним из результатов исследований основ математики, ведущихся с прошлого века. Тема глубокая, в двух словах не пояснить, но поговаривают, что началось всё с обнаружения противоречий в ранней теории множеств, в которую закралась залупа под названием парадокс Рассела. Ты по-любому про неё слышал, даже если не знаешь что такое "матёрый". Короче, проблема известна:
Теория множеств быстро была исправлена, чтобы избежать таких противоречий. Для этого нужно просто запретить множеству содержать само себя в качестве своего элемента. Ну и множество всех множеств тогда тоже придётся запретить.
Идея использовать типы это альтернатива теории множеств, чтобы пофиксить её косяки. Теория типов была предложена тем же Расселом в 1908 г и была развита в Principia Mathematica, написанной Уайтхедом и Расселом в 1910-1913 гг. Суть этого трёхтомника — идея о сводимости математике к логике высказываний (пропозициональной логике). Где-то через 20 лет Хаскелл Карри показывает соответствие между логикой высказываний и комбинаторной логикой. Ещё через 10 лет, в 1940 г Алонзо Чёрч показывает как типизировать λ-исчисление. Постепенно становилось ясно, что типы можно использовать для проверки доказательств и что существует связь с алгоритмами/вычислениями. Это привело к "открытию" соответствия Карри-Ховарда в 1969 г.
Вроде как, впервые проверка доказательств на компе с использованием теории типов была реализована де Брёйном в 1967–1968 гг. в виде теорем-прувера Automath. Эту его штуку никто не рекламировал и походу никто за пределами универа ей и не пользовался, но толчок к дальнейшим исследованиям она дала. Исследования теории типов и её применения подхватили Мартин-Лёф и Робин Милнер в 1970-х. Эти исследования привели в том числе к созданию логики для вычислимых функций и языка ML. Ну и там были ещё всякие крутые математики причастны, которые не были упомянуты.
Вообще люто угарать по метаматематике было модно в первые \(\frac{2}{3}\) того столетия. Начиная с 1934–1935 годов, Бурбаки тоже уделяли внимание понятию математической структуры. Но они использовали теорию множеств, которая, как писал Лео Корри, была там какой-то хуйнёй прилепленной сбоку, навязанной Бурбаки их собственными взглядами на основания математики. А работа над Coq началась во Франции в 1984 г и продолжается до сих пор.
1.2 Нахуя они вообще нужны, эти теорем-пруверы?
Это вопрос, который обычно задают первым делом.
В основном чтобы:
- Формально верифицировать ПО.
- Доказывать теоремы на компах и автоматически проверять неебаца большие доказательства.
И вообще, доказывать теоремы на бумаге неудобно. Чтобы понять почему, достаточно бегло посмотреть на иерархию математических структур в MathComp даже на момент 2023 г и охуеть.
TODO тут должна быть картинка с иерархией этих структур.
Или вот ещё: по версии Quanta Magazine главным достижением в математике за 2024 год стало доказательство гипотезы Ленглендса. Можете ознакомиться с 800-страничным доказательством, разбитым на 5 частей. Я лично рот ебал.
Технический довод, приведённый уважаемым автором, который трудно проверить и который выглядит похожим на уже известные правильные рассуждения, почти никогда не проверяется подробно.
писал Владимир Воеводский в 2014
Получается, что Coq нужен для верификации компьютерных программ и математики. Ну и конечно же кок помогает учиться, это же пруф-ассистент. А ещё он самый хайповый. Не знаю, насколько это правда, но автор пишет, что остальные системы типа Isabelle/HOL, Lean, итд) тупо не получают такого академического признания.
Часто упоминают, что помощью Coq были верифицированы:
- Теорема о 4-х красках Теорема о нечётном порядке Теорема Абеля–Раффини
- Компилятор языка C
Вот, например, как выглядит формулировка теоремы о 4-х красках в Coq:
Theorem four_color (m : map R) : simple_map m -> map_colorable 4 m.
А вот так теорема о нечётном порядке:
Theorem Feit_Thompson (gT : finGroupType) (G : {group gT}) : odd #|G| -> solvable G.
Сложна и нихуя не понятно, мне тоже, это нормально. Вроде как дальше должно стать понятнее. А может и не должно — хуй его знает. Ну и это теоремы из области теории графов и алгебры, а тут речь пойдёт о мат. анализе, рано или поздно.
А ещё, вот тут на первых слайдах есть более развёрнутый, но краткий ответ на вопрос "зачем нужна формальная верификация".
1.3 Это базовый минимум
Все с чего-то начинали, но ты больше не начинай, брат.
Формальное доказательство.
Это доказательство, в котором каждый логический вывод проверен до самых фундаментальных аксиом.
Томас К.Х.
Базовые правила формальных доказательств:
- Все промежуточные логические шаги приводятся без единого исключения
- Не делается никаких обращений к интуиции, даже если переход от интуиции к логике является тривиальным
- Формальное доказательство менее интуитивно, но и менее подвержено логическим ошибкам
Тут щас будет про основы Coq, ssreflect и mathcomp. Мы это всё знаем, поэтому частично пропустим все объяснения и немного понабираем код, чтобы привыкнуть к аппарату.
Вот так мы импортируем базовые штуки из mathcomp:
Module My00. Fixpoint add n m := if n is n'.+1 then (add n' m).+1 else m.
Эмм. Ну понятно. Лучше пока переключимся на курс Антона, ибо он пизже как вводный.
Кстати, вот слайды, которые можно было полистать вместо чтения этого невменяемого текста, но уже поздно.
End My00.
1.4 Курс Антона
Требования к подготовке.
Чтобы было по-кайфу желательно знакомство с основами логики и основами ФП.
Антон рекомендует читать эти 2 книги по ходу дела:
- Programs and Proofs: Mechanizing Mathematics with Dependent Types - I. Sergey
- Mathematical Components book - A. Mahboubi, E. Tassi
Остальное можно найти на гх вики кока.
1.4.1 Простые типы
Сначала нужно импортировать самые базовые штуки из
ssreflect.
From mathcomp Require Import ssreflect.
Определим новый модуль, чтобы избежать конфликтов имен со стандартной библиотекой.
Module My01.
Чтобы лучше разобраться как разные вещи работают "под капотом", мы делаем вид, что изначально у нас нет ничего и мы начинаем самостоятельно постепенно добавлять всякие типы.
Вот так можно определить тип
bool с
двумя конструкторами:
true и
false:
Inductive bool : Type := | true | false.
-
Inductive— ключевое слово для определения нового типа. -
Type— это "тип типов" или "вселенная типов".
Теперь, когда у нас есть какой-то простейший тип
bool, мы можем создавать типы функций с помощью конструктора для типов
функций — стрелочки:
А чтобы создать саму функцию, можно использовать ключевое слово
fun. Вот пример анонимной (\(\lambda \))
функции:
Тип в анонимной функции можно и не писать. Тогда Coq создаст
экзистенциальную переменную
?T, означающую "некоторый тип
?T, который можно вывести из контекста":
Утверждения типизации.
Утверждения типизации вроде \(\Gamma \vdash t : T\) связывают некоторый контекст \(\Gamma\) (гамма) и два терма \(t\) и \(T\).
Ну вот выше написано буквально тоже самое, что мы описали только что словами.
Контекст \(\Gamma\) это такое место, где хранятся все известные нам факты и предположения в тот момент, когда нам встречается терм, который мы видим справа от \(\vdash\). Контекст может быть пустым или содержать типизированные переменные и определения:
Типизированная переменная – пара из имени и типа:
Variable n
: nat
Типизированное определение – тройка из имени, терма и типа:
Definition a
: T
:= t
Попробуем применить ф-цию к аргументу типа
bool.
Опа,
?T
исчезла. Как и ожидалось, Coq вывел тип, используя тип аргумента
(bool).
Пример функции высшего порядка и её тип:
Coq, ясен хуй, умеет вычислять.
Определения и функции
Поехали дальше. Можно определять всякие штуки, вот так:
Definition idb := fun b : bool => b.
С этими "определениями" можно делать те же операции, что и с термами: чекать тип, вычислять и т.д.
Команда
Fail
используется, когда хочется "ожидать ошибку". Пример:
Мы применяем функцию высшего порядка к терму типа
bool, а она хочет тип
bool ->
bool. Кстати, можно даже писать
Fail Fail..., чтобы проверить что "ошибка НЕ произошла (как и
ожидалось)".
Паттерн матчинг. Элиминация. Редукции. Eval
Определим функцию отрицания:
Definition negb := fun (b : bool) => match b with | true => false | false => true end.
Результат
negb true
можно пошагово разложить на последовательность редукций:
negb true
\(\rightsquigarrow \delta\)
(fun
(b
: bool) =>
match b
with |
true =>
false |
false =>
true end) true
\(\rightsquigarrow \beta\)
match true
with |
true =>
false |
false =>
true end
\(\rightsquigarrow \iota \)
false
Выше наглядно показано что именно делают эти редукции, а теперь просто скажем это словами:
- \(\delta\) (дельта-редукция) — разворачивает определение
- \(\beta\) (бета-редукция) — подставляет значения аргумента в тело определения
- \(\iota\) (ёпта-редукция) — выполняет паттерн-матчинг
Можно управлять степенью раскрытия через команду
Eval —
она может применять разные стратегии редукции.
Порядок тут не важен.
Символические вычисления / вычисления с переменными
В новых версиях Coq
просит
помещать
Variable
или
Hypothesis
внутрь секий.
Section MySymbolic.
Теперь можно объявить какую-то переменную.
Variable c : bool.
С переменными можно производить вычисления. Ниже видно как
переменная
c
останавливает цепочку редукций на шаге паттерн матчинга (\(\iota\)
— ёпта-редукции).
Вот два варианта \(\land\) для
bool.
Definition andb (n m : bool) := match n with | false => false | true => m end. Definition andb' (n m : bool) := match m with | false => false | true => n end.
Экстенсионально эти функции равны, в том смысле, что: \(\forall b\ c.\ andb\ b\ c = andb'\ b\ c\).
Но если использовать символьный аргумент, то видно, что их
поведение чуть разное, просто потому что
andb
разбирает первый аргумент, ну а
andb' —
второй.
Интенсиональность и Экстенсиональность.
Функции "равны" интенсионально, если они написаны одинаково. Ну типа буква в букву.
То есть:
- \(f(x) = x + 2\) \(g(x) = x + 2\)
Функции "равны" экстенсионально, если пофиг как они написаны. Главное, что для всех аргументов функция \(f\) выдает тот же результат, что и функция \(g\).
Вот, например, две функции, которые равны экстенсионально:
- \(\displaystyle f(x) = x + 2\)
- \(\displaystyle g(x) = \frac{x^2 - 4}{x - 2} + 2\) для \(x \neq 2\)
Таким образом получается, например, что все функции соритировки списка экстенсионально равны между собой. Короче, экстенсиональность это когда \(\forall x: f(x) = y(x)\). Ну и для функций большего количества аргументов та же фигня.
Пока закроем эту секцию.
End MySymbolic.
Индуктивные типы и рекурсивные функции
В какую психушку? Вы чё, угараете?
Определим новый индуктивный тип для чисел Пеано.
Inductive nat : Type := | O | S of nat.
Напечатем определение.
Проверим тип.
Посмотрим на типы значений.
Унарная запись неудобна для вычислений, но суперудобна для
доказательств. Поэтому унарные числа часто встречаются в
доказательствах и для них есть специальная нотация — можно просто
писать 0, 1, 2, 3 и так далее вместо вот этих термов типа
S (S (S O)). Чтобы можно было просто писать числа, нужно закрыть наш этот
модуль
My01 и
тогда будет использоваться стандартное определение
nat и
станут доступны соответствующие нотации.
Определим пару арифметических операций с этими нашими числами.
Definition succn := S.
Тотальность
С функцией, которая возвращает предыдущее число немного сложнее,
тк хз что возвращать для
O.
Definition predn (n : nat) : nat := match n with | S x => x | O => _ (* А тут что? *) end.
Coq — язык тотальный, то есть хрен там, а не исключение можно кинуть. Нам надо реально вернуть что-то типа nat. Прикинем вообще, какие есть способы написать эту функцию:
-
pred : nat -> nat— просто возвращаем что-то по умолчанию (0 самое логичное, чо уж там) -
pred : nat -> option nat— возвращаемый тип можно сделатьoption -
pred : forall n : nat, (n <> 0) -> nat— потребоватьn <> 0, чтобы гарантировать, что функцию не вызовут с нулём.
На практике используется, как правило, первый вариант.
Definition predn (n : nat) : nat := match n with | S x => x | O => O end.
То же самое и с делением — если делим на ноль, просто возвращаем ноль и не ебём мозги. Казалось бы будто так можно всю логику сломать, но ничего подобного. Просто местами придётся добавить парочку предусловий в леммы.
Рекурсия
Классика: сложение двух чисел. Это рекурсивная функция, поэтому
надо использовать команду
Fixpoint
вместо
Definition.
Fixpoint addn (n m : nat) {struct n} : nat := match n with | O => m | S n' => S (addn n' m) end.
Кусок
{struct
n}
указывает по какому параметру идёт рекурсивный вызов — тут это
n. Мы как будто подсказываем Coq'у какой терм структурно
уменьшается. Конкретно для
addn не
обязательно было это указывать, так как Coq по умолчанию
предполагает, что это будет первый аргумент. Дальше мы увидим, что
бывают случаи когда это необходимо.
\(2 + 2 = 4\)
Как это можно записать более идиоматично.
Fixpoint addn_idiomatic (n m : nat) : nat := if n is S n' then S (addn_idiomatic n' m) else m.
Fixpoint это сахар.
Внутри у
Fixpoint
всё тот же
Definition, а ещё
fix, ну а
fix
это
комбинатор неподвижной точки.
Посмотрим что там внутри.
Имя после
fix
используется для рекурсивных вызовов и оно не обязано совпадать с
настоящим именем функции, хоть хуем назови, т.е. можно было бы и
так:
Definition addn_no_sugar := fix hooy (n m : nat) {struct n} : nat := match n with | O => m | S n' => S (hooy n' m) end.
Определим ещё одну функцию сложения, но в этот раз используем более идиоматичный синтаксис и сделаем рекурсию по второму аргументу.
Fixpoint addn' (n m : nat) {struct m} := if m is (S m') then S (addn' n m') else n.
Щас снова будем работать с символьными вычислениями, поэтому нужно делать это внутри секции. Да, секции можно "переоткрывать".
Section MySymbolic. Variable z : nat.End MySymbolic.
Допустим мы сделали ошибку и ебанули рекурсию не по структурно
меньшему
m', а снова по
m. В таком случае тоталити чекер скажет нам
"ты чо творишь ёбаный рот, ты сказал, что шаришь в этой
теме".
Видно, что он прям синтаксис проверяет, чтобы находить такие косяки.
Взаимная рекурсия
Fixpoint is_even (n : nat) : bool := if n is S n' then is_odd n' else true with is_odd n := if n is S n' then is_even n' else false.
Обе эти взаимно-рекурсивные функции —
is_even
и
is_odd —
теперь доступны для использования.
Закроем наш корневой модуль.
End My01.
Теперь все его определения получат префикс имени модуля.
Дальше мы забиваем на свои самописные версии и переключаемся на готовые реализации из MathComp.
From mathcomp Require Import ssrnat ssrbool.
После импорта Coq предупреждает: "некоторые стандартные нотации перекрыты". Нам вообще похую. Эти ворнинги можно отключить вот так:
Set Warnings "-notation-overridden".
Теперь над доступны функции для работы с
bool и
nat из
библиотеки
mathcomp.
Module My02.
Вот, например, \(a \oplus b\). Это
операция
XOR или
другими словами "сложение по модулю 2".
С помощью команды
About
можно узнать чуть больше инфы, чем сообщает
Check.
А вот и упомятый выше сахар для натуральных чисел Пеано.
Отключение/включение синтаксического сахара.
Set Printing All.Unset Printing All.
Как выяснить значения непонятных нотаций.
Хотя, иногда бывает сложно понять, что является частью нотации, а что нет.
Section MySymbolic. Variable x : nat.End MySymbolic.
Короче, нотации это просто удобные текстовые сокращения, их можно свободно менять. А определения это фундаментальные объекты в теории: они влияют на всё.
End My02.
Практика
Теперь немного практики — сделаем домашку из курса. Я это уже всё решал лет 5 назад, но всё к забыл. Решу всё ещё разок, заново.
Module My03.
Продолжаем работать с нашими собственными определениями булевых значений и натуральных чисел.
Inductive bool := | true | false. Definition negb := fun (b : bool) => match b with | true => false | false => true end.
Упражнение 1: Булевы функции.
1a. Определите функцию
orb, реализующую дизъюнкцию (логическое "или") для булевых
значений, и тщательно протестируйте её при помощи команды
Compute.
Definition orb (b c : bool) : bool := match b, c with | false, false => false | _, _ => true end.
1b. Определите функцию
addb, реализующую
XOR
(\(a \oplus b\)). Попробуйте придумать
более одного способа реализации (не просто поменять местами
аргументы) и исследуйте, как ведёт себя редукция функции при
наличии символических переменных.
Section MySymbolic.
Для наглядности распишем все кейсы.
Definition addb0 (b c : bool) : bool := match b, c with | true, true => false | true, false => true | false, true => true | false, false => false end. Variable x : bool.
Попробуем теперь использовать анонимные функции.
Definition addb1 := fun (b : bool) => fun (c : bool ) => match b, c with | true, true => false | false, false => false | _, _ => true end.
Ну символически-вычисленные термы одинаковы. А что будет, если сравнить цепочки редукций?
Ну очевидно, что оно всё одинаковое. Но пока у нас разница между
addn0 и
addn1
только в том, используем ли мы анонимные функции или нет. Похоже
это вообще никак не влияет на цепочку редукций и в задаче
требовалось проверить какое-то другое различие.
А что, если добавить какую-нибудь комбинацию \(\land\), \(\lor\) или \(\lnot\) в тело функции, но так, чтобы это не повлияло не результат? Ну типа сделать эти две функции экстенсионально одинаковыми, но интенсионально различными?
Definition addb2 (b c : bool) : bool := orb (negb (orb (negb b) c)) (negb (orb b (negb c))).
Сравним теперь цепочки редукций.
End MySymbolic.
1c. Определите функцию
eqb, реализующую проверку равенства на булевых значениях, то есть
eqb b
c
должно возвращать
true
тогда и только тогда, когда
b и
c равны.
Добавьте юнит-тесты. Под юнит-тестами тут и дальше имеется ввиду,
что надо просто сделать
Compute
и убедиться в корретности результата.
Definition eqb (b c : bool) := negb (addb0 b c).
Упражнение 2: Арифметика.
Inductive nat : Type := | O | S of nat.
2a. Определите функцию
dec2
типа
nat ->
nat, которая уменьшает натуральное число на 2. Например, для числа
5
должно вернуться
3. Напишите несколько юнит-тестов для
dec2. Что функция должна возвращать для
1 и
0?
Будем
O
возвращать, практичности и удобства ради.
Definition dec2 (n : nat) := if n is (S (S n')) then n' else O.
2b. Определите функцию
subn
типа
nat ->
nat ->
nat, которая принимает два натуральных числа
m и
n и
возвращает результат вычитания
n из
m. Например,
subn 5
3
должно возвращать
2. Напишите юнит-тесты.
Fixpoint subn0 (m n : nat) : nat := match m, n with | O, _ => O | _, O => m | S m', S n' => subn0 m' n' end.
Или вот более идиоматичное определение вычитания.
Fixpoint subn1 (m n : nat) {struct m} := if m is S m' then if n is S n' then m' - n' else m else m where "m - n" := (subn1 m n) : nat_scope.
2c. Определите функцию
muln
типа
nat ->
nat ->
nat, которая принимает два натуральных числа
m и
n и
возвращает результат их умножения. Напишите юнит-тесты.
Умножение это как будто сокращённое сложение: \(a * 3 \equiv a + a + a\). Поэтому определим сначала операцию сложения для наших чисел.
Fixpoint addn (m n : nat) {struct n} : nat := if n is S n' then S (addn m n') else m where "m + n" := (addn m n) : nat_scope.
Проверим, что это вообще работает как мы ожидаем.
Теперь определим:
\(m * n \equiv m + \dots + m\).
Fixpoint muln (m n : nat) {struct n} : nat := if n is S n' then m + (m * n') else O where "m * n" := (muln m n) : nat_scope.
2d. Реализуйте функцию сравнения на равенство
eqn для
натуральных чисел типа
nat ->
nat ->
bool. Она должна возвращать
true
тогда и только тогда, когда числа равны.
Fixpoint eqn (m n : nat) : bool := match m, n with | O, O => true | S m', O => false | O, S n' => false | S m', S n' => m' = n' end where "m = n" := (eqn m n) : nat_scope and "m <> n" := (negb (m = n)) : nat_scope.
2e. Реализуйте функцию сравнения "меньше или
равно"
leq для
натуральных чисел типа
nat ->
nat ->
bool. Выражение
leq m
n
должно возвращать
true
тогда и только тогда, когда
\(m \leq n\). Решение не должно
использовать рекурсию, но можно переиспользовать ранее
определённые функции.
Definition leq (m n : nat) : bool := (n - m) <> O.
В этот раз ради прикола отдельно определим нотацию
\(\leq\) для функции
leq.
Notation "m <= n" := (leq m n) : nat_scope.
Дополнительное. Реализуйте деление
divn на
этих ваших натуральных числах и напишите несколько юнит-тестов для
него.
Аналогично тому, что умножение это фактически сокращённая запись сложения. Деление должно как-то получаться из серии вычитаний.
Возьмём, например число \(12 : 3 = 4\). Попробуем вычитать из \(12\) его делитель \(3\), пока оно не обратится в \(0\). Cколько раз это можно сделать?
\(12 - 3 - 3 - 3 - 3 = 0\).
Всего \(4\) раза — это и есть частное.
А тут хуяк и допустим мы забыли чо такое этот минус:
subn0
или subn1?
О, это
subn1. Ну короче искать мы умеем.
Fixpoint divn (m n : nat) : nat := if n is S n' then if m - n' is S m' then S (m' / n) else O else O where "m / n" := (divn m n) : nat_scope.
У меня 10 тыщ пенсия. Я хуй без соли доедаю. Ёлочка мне нравится? Елочка блять мне нравится.
Теперь вспомним что там были за задачки на семинаре.
End My03. Module My04.
Семинар
Упражнение 1: Реализуй функции с указанными типами.
Definition prodA {A B C : Type} : (A * B) * C -> A * (B * C) := fun abc => match abc with | ((a, b), c) => (a, (b, c)) end.Definition sumA' {A B C : Type} : (A + B) + C -> A + (B + C) := fun abc => match abc with | inl ab => match ab with | inl a => inl a | inr b => inr (inl b) end | inr c => inr (inr c) end.
Другая возможная реализация с ипользованием вложенного паттерн-матчинга - так даже более идиоматично:
Definition sumA (A B C : Type) : (A + B) + C -> A + (B + C) := fun abc => match abc with | inl (inl a) => inl a | inl (inr b) => inr (inl b) | inr c => inr (inr c) end.Definition prod_sumD (A B C : Type) : A * (B + C) -> (A * B) + (A * C) := fun '(a, bc) => match bc with | inl b => inl (a, b) | inr c => inr (a, c) end.Definition sum_prodD (A B C : Type) : A + (B * C) -> (A + B) * (A + C) := fun a_bc => match a_bc with | inl a => (inl a, inl a) | inr (b, c) => (inr b, inr c) end.
Упражнение 2: Композиция функций.
Definition comp A B C (f : B -> A) (g : C -> B) : C -> A := fun x => f (g x).
Введи нотацию, чтобы можно было использовать композицию вот так:
\(f \circ g\). Возможно, тебе придётся
немного подправить настройку неявных аргументов у
comp.
Arguments comp [A B C] _ _ _.
В
ssrfun
есть уже нотация для композии функций, можно использовать её.
Но если б её не было, ты мог бы её определить вот так:
Notation
"f \o g"
:= (comp f
g)
(at level
70).
Введём пустой тип.
Тут получаем предупреждение, тк мы объявили тип
void во
вселенной типов (Type), но петушара автоматически понизил его до
Prop
(логических утверждений). Такое автоматическое поведение с
понижением до
Prop —
устаревшее и скоро станет невозможным.
-
Coq различает два главных юниверса:
Type(обычные типы: множества, структуры) иProp(логические утверждения — истинные или ложные). -
В старых версиях Coq, если ты объявляешь
voidкак тип вType, но используешь его там, где ожидаетсяProp, Coq автоматически переводит его вProp(это называется "prop lowering"). -
Текущий warning говорит: "Я объявлен в
Type, но меня используют какProp→ делаю автоматическое понижение". Это скоро перестанет делаться автоматически, чтоб избежать неявного поведения.
Из пустоты (неконструируемого) можно получить всё, но сначала предъяви пустоту (неконструироемое).
Definition void_terminal (A : Type) : void -> A := fun x => match x with end.
Введём тип unit — это тип, у которого ровно одно значение (каноническая форма).
Дай что угодно и получишь это самое, существующее в 1 экземпляре.
Definition unit_initial (A : Type) : A -> unit := fun _ => tt.
Подумай над другими сигнатурами типов, использующими
void, unit, sum и
prod:
-
(void + unit) * unit -> unit
-
void -> void + void
-
void * unit + void -> void * unit
End My04.
1.4.2 Полиморфные функции. Параметризованные типы
Module My05.
Полиморфные функции и зависимые функции
Была у нас такая функция:
Definition id
:= fun
(b
: bool) =>
b.
Теперь мы хотим такую же, но только чтобы она работала на всех
типах, а не только на
bool. То есть нам нужна функция не типа
bool ->
bool, а типа \(\alpha \rightarrow \alpha\).
Функции такого типа называются
параметрически полиморфными. Но почему-то
следующий код не работает:
Проблема примера выше в том, что Coq не знает, что такое
A: она нигде не определена и не связана. Нам нужно явно это
указать. Иными словами, мы щас попытались использовать
параметрический полиморфизм в неявной форме, а Coq поддерживает
его только в явной форме. По сути, мы хотим сказать
что-то вроде
id :
(A
: Type) ->
A ->
A. Однако это выражение в Coq не означает то, что мы имеем в виду.
Правильный синтаксис в Coq таков:
forall ident
: term, term,
где в нашем случае под
term
следует понимать тип.
Ну короче:
Definition id : forall A : Type, A -> A := fun A : Type => fun x : A => x.
Пример использования:
Теперь функция получает два аргумента — сначала тип, затем
значение. Чтобы применить
id к
данным вроде
true или
42, нужно явно указывать тип. Позже мы научимся, как обходиться без
явного указания типа.
Ну и мы только что познакомились с
зависимыми типами, потому что по факту тип
результата функции
id
зависит от её первого аргумента.
В теории типов это называется \(\pi\)-типы ("пи"-типы). Можно попробовать почитать в вики про пи-типы, но как по мне, если ты заранее не знаешь о чём речь и не знаком с теорией, то вкуривание той вики-странички будет напряжно.
Типы в Coq — это такие же термы, как и любые другие. Они являются "объектами первого класса" (на русском это странно звучит, вообще термин звучит как "first-class citizen"). Это означает, что в Coq типы можно хранить их в структурах, передавать и возвращать из функций.
Зависимая функция.
forall x
: A,B
называется зависимо типизированной функцией из
A ->
B(x), где
B(x)
может ссылаться на
x. Ещё иногда такие функции называют:
"зависимая функция",
"функция с зависимым типом" — это
синонимы. Ну а
B
называют семейством типов, потому что для
каждого
x
существует свой тип
B(x).
В Coq нет какого-то особого стрелочного типа
->. Все функции — зависимо типизированные, а
-> —
это просто нотация. Проверим:
Петушара показывает, что это тоже зависимая функция, просто в ней
параметр не используется (поэтому подчёркивание). Таким образом,
обычный тип
A ->
B
— частный случай зависимого типа.
Coq не запрещает явно именовать параметры, даже если они не используются:
Definition id' : forall A : Type , forall x : A , A := fun A : Type => fun x : A => x.
Ну короче, если хочется погрузиться и начать шарить в этой теме, то можно почитать Introduction to the Calculus of Inductive Constructions, а пока нас это ебать не должно.
Тип-произведение
Каррирование
позволяет работать с функциями любой арности (любого количества
аргументов). Например, сложение имеет тип
nat ->
nat ->
nat, что тоже самое, что и
nat ->
(nat
-> nat). Но что если функция возвращает несколько результатов, как,
например, при целочисленном делении нужно вернуть частное и
остаток?
Inductive prodn : Type := | pairn of nat & nat.
pairn of
nat &
nat
означает, что конструктор
pairn
содержит 2 натуральных числа.
Это назвается и тип-произведение, потому что по смыслу это тоже самое, что и прямое (декартово) произведение, только на типах, а не на множествах.
Теперь мы могли бы реализовать, например, деление:
divmod :
nat ->
nat ->
prodn, но тут появляется проблема — пришлось бы создавать свой
"тип-произведение" под каждую комбинацию типов:
nat*bool,
bool*nat, и т.д.
Параметризованные индуктивные типы
Вместо этого мы можем использовать параметризованный индуктивный тип:
Inductive prod (A B : Type) : Type := pair of A & B.
Важно понимать, что
prod это
не тип, а конструктор типа. Проверим:
Видимо потому, что выговаривать "конструктор типа"
каждый раз долго, люди часто всё равно называют их просто
"типами". В общем, чтобы получить именно тип, нужно
применить его к аргументам
A,
B:
Предлагается ещё помедитировать над этим:
Неявные аргументы
Всё время явно указывать типы запаришься. Особенно когда сам Coq
прекрасно может догадаться, что ты имел в виду. Например, он ведь
точно знает, что у числа
42 тип
nat, а у
true тип
bool. Значит, он вполне может вывести это сам. К счастью петушок
умеет выводить их автоматически через
неявные аргументы. Давай включим их для
id и
pair:
Arguments id [A] _. Arguments pair [A B] _ _.
Строка
Arguments id
[A] _.
сообщает коку следующее: параметр типа
A теперь
будет выводиться автоматически, поэтому нам больше не нужно его
указывать вручную. Для
pair та
же фигня, только Coq тут выведет сразу оба типа —
A и
B. Теперь можно писать так:
Кстати, вызов
pair nat
bool 42
true
(с явной передачей аргументов типа) теперь зафейлится —
nat и
bool
стали неявными, и Coq сам должен их вычислить.
Это не значит, что мы поменяли сам тип
pair. Нет, внутри всё осталось как раньше — Coq просто как бы добавил
сверху такой удобный слой, чтобы нам не приходилось писать много
руками. Такой прикол часто используют в ML‑подобных языках, откуда
вдохновлялись видимо разработчики кока.
Если вдруг нужно, наоборот, отключить неявное определение и
заставить Coq требовать все аргументы явно, используется
@. Это такое "локальное отключение вывода аргументов".
Собачка.
Оператор
@
локально отменяет вывод неявных аргументов.
Претти-принтер учитывает, какие аргументы сделаны неявными, и по умолчанию их не показывает — даже если ты сам их написал руками. Иногда это мешает, например, когда надо точно понять, что именно Coq подставил под капотом. Чтобы увидеть всё "как есть", включаем специальный режим:
Set Printing Implicit.Unset Printing Implicit.
Для сравнения:
В общем, все команды, начинающиеся с
Set ..., меняют поведение Coq глобально. Поэтому, если включил —
желательно потом выключить через соответствующую
Unset ...
команду.
Да, каждое новое определение или тип прописывать с
Arguments
вручную немного напрягает. Чтобы Coq сам решал, какие параметры
можно сделать неявными, есть глобальная команда:
Set Implicit Arguments.
С этого момента и до конца файла (или пока не встретится
Unset Implicit
Arguments) Coq сам будет пытаться выяснить, какие аргументы можно брать
неявно. Можно сказать, у него включается режим "я шарю в этой
теме".
Нотации
Работать с парами через
prod и
pair
напрямую неудобно и громоздко. Все обычно пользуются более
привычными обозначениями вроде
A *
B
для типа произведения и
(a, b)
для конкретной пары.
К счастью, Coq позволяет вводить свои собственные нотации — по сути, добавлять новые правила для парсера прямо "на лету". Вообще система нотаций в Coq довольно хитрая и многослойная, но сейчас мы глянем только основное.
Вот пример нотации для типа произведения (prod). Дальше ниже разберём, что здесь происходит.
Заметь, Coq выдаст предупреждение, что нотация уже переопределяется — это потому что стандартная библиотека уже содержит аналог.
А теперь разберём по пунктам, что всё это значит:
-
Notationговорит Coq, что мы сейчас добавим новую синтаксическую конструкцию - Определяемая нотация берётся в двойные кавычки (чтобы показать точный вид, включая пробелы и символы)
-
Идентификаторы вроде
AиBавтоматически становятся связанными переменными внутри тела после:= -
Само тело нотации
(prod A B)обязательно помещается в скобки — это то, во что Coq будет реально разворачивать запись при парсинге -
Далее задаётся грамматическая информация, например уровень
приоритета (
at level 40) и ассоциативность (left associativity). Чем level ниже, тем сильнее нотация "сцепляется" с другими операторами -
После этого указывают область видимости — здесь это
type_scope, то есть нотация будет применяться, когда Coq ожидает тип - При желании можно ещё добавить правила для pretty-printer'а. В этом примере это не показано
Теперь, когда мы напишем
A *
B, петушара сам поймёт, что имеется в виду
prod A
B. Как только Coq встречает подобную запись, то он добавляет
соотвествующее правило в некоторую свою таблицу грамматических
правил.
Области нотаций
Один и тот же символ можно использовать для разных вещей. В нашем
случае мы уже переиспользовали
* —
теперь это не умножение, а тип произведения. Но по умолчанию
*
вообще-то означает именно умножение для натуральных чисел.
В выводе команды
Locate
можно увидеть, что у символа есть "default
interpretation". Это значит, что если Coq не понимает, в
каком смысле ты используешь
*, он воспримет его как умножение. Поэтому следующая проверка не
сработает:
Нужно подсказать Coq'у, что это не оператор умножения, а тип
произведения двух других типов. Чтобы объяснить Coq, что здесь
подразумевается тип произведения, надо указать "область
нотации" — с помощью
%type:
Альтернативный способ сделать тоже самое:
В этом случае Coq понимает, что в скобках написан тип, а не терм
(житель/значение типа), и значит
* — это
нотация не для
Nat.muln, а для
prod.
Ещё один способ поменять значение символа по умолчанию — это
открыть соответствующую область -
Open Scope. В Coq области (scopes) ведут себя как стек: последняя открытая
становится текущей.
Open Scope type_scope.
Опа, дефолтная интерпретацию нотации "*" изменилась.
Когда открытая область больше не нужна, её можно закрыть — это "вытолкнет" её со стека и вернёт старое поведение.
Close Scope type_scope.
Кстати, про
left associativity, которое мы указали в определении нотации
A *
B. Это нужно для того, чтобы можно было писать кортежи без
дополнительных скобок. Например, тройку из типов
nat,
bool и
nat
можно описать так:
Из-за ассоциативности влево можно спокойно опустить скобки.
А если наоборот, получится другой, хоть и изоморфный, тип.
Раз уж мы завели удобную нотацию для
prod, сделаем то же самое для
pair, чтобы можно было писать
(42, true)
вместо
pair 42
true.
Первая наивная попытка сделать эту нотацию могла бы быть такой:
Кстати, пробелы после открывающей и до закрывающей скобки обязательны.
Это работает, но только для пар, а вот так уже не получится:
Check (1;
false;
2).
А мы ведь хотим писать тройки, четвёрки и т.д. Для этого есть рекурсивные нотации:
Notation "( p , q , .. , r )" := (pair .. (pair p q) .. r) : core_scope.
Здесь мы указываем
core_scope
для нашей нотации, ибо
pair в
отличии от
prod это
конструктор значения, а не конструктор типа.
Теперь можно, наконец, использовать всё это добро для простых
функций с парами. Например, напишем проекции —
fst и
snd.
Definition fst {A B : Type} : A * B -> A := fun p => match p with | (a, _) => a end.
Пока что будем думать что
{A
B :
Type}
и
[A
B :
Type]
- одно и тоже. В том смысле, что
A и
B
объявлены как неявные параметры.
Если не очень понятно что именно какая-то нотация развернётся, то можно попросить петушка вывести полную форму без всяких сокращений:
Unset Printing Notations.Set Printing Notations.
Теперь пишем вторую проекцию, тут всё то же самое:
Definition snd {A B : Type} : A * B -> B := fun p => match p with | pair _ b => b end.
В ssreflect есть привычная нотация для этих проекций — её тоже сделаем:
Notation "p .1" := (fst p). Notation "p .2" := (snd p).
А вот функция, которая меняет местами компоненты пары:
Definition swap {A B : Type} : A * B -> B * A := fun p => match p with | (a, b) => (b, a) end.
Тип-сумма
Inductive sum (A B : Type) := | inl of A | inr of B.
Аналогичные типы это:
-
Result.tвOCaml -
EitherвHaskell
Наш этот тип
sum
можно использовать для тех же целей, для которых используются его
аналоги. Например, какой-то парсер может иметь тип:
String ->
AST +
Error. То есть он либо попарсил успешно и вернул
AST, либо не смог и вернул
Error.
- Это смысл нашей передачи - наваливать кринге. - Ты правда веришь, что у меня Лилит в первом доме и я устраиваю кринж? - Конечно, моя луна подсвечивает твоё солнце, а это значит, что ты настоящий бунтарь.
А теперь задачки.
End My05.
Нам нужно будет работать со списками, поэтому сразу импортируем
seq.
From mathcomp Require Import seq. Module My06.
Практика: Язык арифметических выражений
Определим простой язык, состоящий из констант и произвольно вложенных операций сложения, вычитания и умножения.
Inductive expr : Type := | Const of nat | Plus of expr & expr | Minus of expr & expr | Mult of expr & expr.
Затем создадим специальную нотацию для нашего языка. Здесь мы повторно используем стандартные арифметические обозначения, но только внутри двойных квадратных скобок.
Declare Custom Entry expr.
Вот это вот выше означает, что мы объявляем
expr
идентификатором для нотации.
Сообщаем парсеру, что
expr
связан с двойными скобками. Это значит, что внутри двойных скобок
парсер будет использовать нотации, связанные с нашим
expr.
Notation "'[[' e ']]'" := e (e custom expr at level 0).
Делаем так, чтобы числа можно было использовать без обёртки в
конструктор
Const.
Notation "x" := (Const x) (in custom expr at level 0, x bigint). Notation "( x )" := x (in custom expr, x at level 2). Notation "x + y" := (Plus x y) (in custom expr at level 2, left associativity).
Упражнение 1: Определить нотации для вычитания и умножения.
Подсказки:
- меньший уровень означает более высокий приоритет
-
нотации должны начинаться с
in custom exprкак выше
Notation "x - y" := (Minus x y) (in custom expr at level 2, left associativity). Notation "x * y" := (Mult x y) (in custom expr at level 1, left associativity).
Вот как записывается
Plus (Const 0) (Plus (Const 1) (Const 2)):
А вот так
Plus (Plus (Const 0) (Const 1)) (Const 2):
Упражнение 2: Убедитесь, что следующие строки парсятся как ожидается. Какой запрос можно использовать для этого?
Можно выключить претти-принтинг нотаций и посмотреть глазами что именно там скрывается под ними.
Unset Printing Notations.Set Printing Notations.
Вот тут ещё есть хороший пример работы с нотациями и я мб ниже ещё накину пару своих чуть более детальных примерчиков (если не забуду).
Упражнение 3: Напишите вычислитель для языка
выражений, который определяет семантику. Семантика языка выражений
должна совпадать с соответствующими функциями:
addn, subn, muln.
Напишем:
Fixpoint eval (e : expr) : nat := match e with | Const n => n | Plus e1 e2 => eval e1 + eval e2 | Minus e1 e2 => eval e1 - eval e2 | Mult e1 e2 => eval e1 * eval e2 end.
Тесты для
eval.
erefl.
Нам пока не объяснили, что значит
erefl. Пока просто поверим, что если эти строки выше проходят
проверку типов, то равенства верны.
Практика: Компиляция арифметических выражений в стековый язык
Брат, всегда сдавайся, не иди до конца.
Вот "низкоуровневый" стековый язык, который может служить целевым языком для компилятора из языка арифметических выражений выше:
Inductive instr := | Push (n : nat) | Add | Sub | Mul.
Программа на этом языке — список инструкций, каждая из которых манипулирует стеком натуральных чисел. Есть инструкции для помещения констант в стек, а также для сложения/вычитания/умножения двух верхних элементов стека, снятия их со стека и помещения результата обратно в стек.
Можно определить собственные нотации для операций со стеком, чтобы удобнее писать тесты. Сделаем их такими милыми кошечками, с ушками:
Notation " <^ n ^> " := (Push n) (at level 0, n constr). Notation "<^+^>" := Add (at level 1). Notation "<^-^>" := Sub (at level 1). Notation "<^*^>" := Mul (at level 1).Unset Printing Notations.Set Printing Notations.
Можно определить свой собственный тип для списков, но мы будем
использовать тип
seq из
MathComp, который мы имортировали выше.
Кстати,
seq —
это просто нотация для стандартного типа
list.
^ Сильно вникать не надо, там дофига чего написано, надо просто посмотреть на первую строчку.
Можно создавать списки так:
-
[::]— нотация для конструктораnil -
x :: xs— нотация для конструктораcons -
[:: 1; 2; 3]— последовательность из трёх элементов 1, 2 и 3
Вот пример:
Ну короче, используя
seq, определим тип "прога" вот так:
Definition prog := seq instr.
Пример проги
(5
+ 4) *
2 -
9:
А тип "стек" так:
Definition stack := seq nat.
Упражнение 4: Реализовать функцию
run -
интерпретатор нашего стекового языка. Она принимает программу
(список инструкций) и текущий стек, и исполняет программу
пошагово, возвращая итоговый стек.
Я сделаю отдельно функцию "шага", мне так будет проще
написать
run.
Тесты:
Fixpoint run (p : prog) (s : stack) : stack := if p is i :: p' then run p' (step i s) else s.
Тесты:
Упражнение 5: Реализовать функцию
compile
- компилятор из "высокоуровневых" выражений в
"низкоуровневые" стековые программы и написать несколько
тестов.
Идеи для тестов:
-
Проверить, что
run (compile e) [::] = [:: eval e], гдеe— произвольное выражение -
Проверить, что
compile— инъективная функция
Fixpoint compile (e : expr) : prog := match e with | Const x => [:: <^x^>] | Plus x y => compile x ++ compile y ++ [:: <^+^>] | Minus x y => compile x ++ compile y ++ [:: <^-^>] | Mult x y => compile x ++ compile y ++ [:: <^*^>] end.
Тесты:
Unset Printing Notations.Set Printing Notations.
То, что надо.
Теперь проверим, что
run (compile e) [::]
= [::
eval e].
Убедимся, что
compile
- инъективная функция. По определению
инъективности
это означает, что нам хочется проверить вот такое свойство
\(f(x_1) = f(x_2) \Rightarrow x_1 = x_2\). Хм, опробуем понять что это может значить в нашем случае для
compile:
compile(e1)
= compile(e2) =>
e1 =
e2
Ну, наверное, от нас хотят рассуждения вроде таких:
-
eval(e1) = eval(e2) => e1 = e2 -
run(is1) = run(is2) => is1 = is2 -
run(compile(e1)) = run(compile(e2)) => eval(e1) = eval(e2) => e1 = e2
Фиг знает.
Упражнение 5: Реализуйте декомпилятор,
преобразующий стековую программу
prog
обратно в выражение
expr.
Подсказка: Возможно, стоит ввести рекурсивный
вспомогательный метод
decompile'
для переиспользования в
decompile.
Тут задача сводится к преобразованию списка инструкций в дерево выражений.
Fixpoint decompile' (p : prog) (s : seq expr) : option (seq expr) := match p with | [::] => Some s | i :: p' => match i with | Push x => decompile' p' (Const x :: s) | Add => match s with | e2 :: e1 :: es => decompile' p' (Plus e1 e2 :: es) | _ => None end | Sub => match s with | e2 :: e1 :: es => decompile' p' (Minus e1 e2 :: es) | _ => None end | Mul => match s with | e2 :: e1 :: es => decompile' p' (Mult e1 e2 :: es) | _ => None end end end.Definition decompile (p : prog) : option expr := match decompile' p [::] with | Some [:: e] => Some e | _ => None end.End My06.
1.4.3 Логика, равенство, зависимый паттерн-матчинг
Нам нужно построить свою собственную логику, в которой мы будем выражать обычные связки вроде "и", "или", "не" и т.д, а также кванторы, используя стиль натуральной дедукции.
Логика, которую мы собираемся эмулировать в теории типов, классифицируется как интуиционистская логика высшего порядка. Считается, что отцом интуиционизма считается Брауэр. С 1904 года Брауэр последовательно проводил критику так называемых чистых математических доказательств существования, опирающихся на логический принцип исключённого третьего, что в конечном счёте положило начало целому направлению в обоснованиях математики математическому интуиционизму.
Антон в одной из свох лекций рассказывал, что в то время Брауер заметил, что многие современные ему математики не предъявляют конкретных конструкций. В частности Гильберт популяризовал доказательства от противного. Ну типа предполагаем, что что-то не существует, выводим противоречие, снимает двойное отрицание и мы доказали существование объекта без его предъявления.
Иногда люди используют слова "конструктивисткая" и "интуиционистская" как синонимы. Строго говоря, это не совсем корректно, но мы не будем душнить.
В конструктивной логике существование математического объекта доказывается с помощью метода (или алгоритма), который реально этот объект строит. В теории типов такие методы выражаются как термы.
Между термами и типами с одной стороны, и доказательствами и высказываниями — с другой, существует соответствие. Теория типов позволяет не только эмулировать логику высшего порядка, но и оперировать самими доказательствами как полноценными объектами: передавать их в функции, упаковывать в структуры данных, возвращать из функций и так далее. Это делает из теории типов мощный инструмент для математических рассуждений и верификации программ.
From mathcomp Require Import ssreflect ssrfun. Set Implicit Arguments. Module My07.
Импликация
Импликация соответствует типу функции. Доказательство утверждения
"A влечет B" — это просто функция типа
A ->
B, которая преобразует доказательство
A в
доказательство
B.
Вот доказательство того, что из
A
следует A:
Definition a_implies_a (A : Prop) : A -> A := fun proof_of_a : A => proof_of_a.
Это просто тождественная функция, как мы уже видели ранее и её
можно понимать как лемму под названием
a_implies_a.
Мы используем вселенную
Prop —
это примитив Coq, о котором будет рассказано чуть ниже. Сейчас
достаточно знать, что
A :
Prop
значит "A — это высказывание".
Prop.
Prop
(от слова "proposition") это вселенная, где живут
высказывания.
Если
A:
Prop, то
A это
утверждение или высказывание.
Еще один пример:
Definition A_implies_B_implies_A (A B : Prop) : A -> B -> A := fun proof_a => fun proof_b => proof_a.
Это соответствует функции
const.
А modus ponens - это просто применение функции:
Definition modus_ponens (A B : Prop) : A -> (A -> B) -> B := fun p_a p_a_impl_b => p_a_impl_b p_a.
Конъюнкция
Конструктивное доказательство конъюнкции
\(A \land B\) — это пара доказательств
A и
B:
Inductive and (A B : Prop) : Prop := | conj of A & B. Notation "A /\ B" := (and A B) : type_scope.
Заметь, насколько
and
похож на
prod:
Inductive prod (A B : Type) : Type := | pair of A & B.
Докажем, что конъюнкция коммутативна.
Definition andC (A B : Prop) : A /\ B -> B /\ A := fun p_a_and_b => match p_a_and_b with | conj p_a p_b => conj p_b p_a end.
Это доказательство выглядит точь‑в‑точь как функция, переставляющая местами компоненты пары.
Дизъюнкция
Конструктивное доказательство дизъюнкции
\(A \lor B\) — это либо доказательство
A, либо доказательство
B, плюс "тэг/метка", показывающая, какое именно из
высказываний доказано. Короче, определение дизъюнкции выглядит
так:
Inductive or (A B : Prop) : Prop := | or_introl of A | or_intror of B. Notation "A \/ B" := (or A B) : type_scope.
Мы там выше уже немного потыкали неявные аргументы, но вот таких штук пока не встречали, поэтому надо бы разобраться:
Arguments or_introl [A] B _, [A B] _. Arguments or_intror [A] B _, [A B] _.
Тут
Arguments
указывает на то, какие есть варианты указать аргументы. В каком
случае какие аргументы должны выводиться неявно, а какие
указываться явно.
В примере выше у нас 2 варианта:
Вариант [A] B _
Используется в случае применения конструктора
or_introl
(or_intror) к двум аргументам и означает, что:
-
Первый аргумент
[A]может быть выведен неявно -
Второго аргумент
Bнужно подставить самому (явно) -
_это само доказательствоA, ну т.е. этоor_introl of A
[A]
можно вывести неявно потому, что доказательство
or_introl of
A
уже имеет тип
A. Ну просто потому что все конструкторы наследуют параметры
самого индуктивного типа, который они конструируют:
Вариант [A B] _
Иногда
B может
быть выведен из контекста (ниже будет пример такого случая). Так
вот в варианте
[A
B]
_
мы говорим, что если
or_introl
применён к всего лишь одному аргументу, то нужно вывести из
контекста оба утверждения:
A и
B (если
не получится это сделать, то будет ошибка).
И снова — полное сходство с
sum:
Inductive sum (A B : Type) : Type := | inl of A | inr of B.
Разница лишь в том, что
or живёт
во вселенной
Prop, а
sum — в
Type.
А вот так будет выглядеть конструктивной логике доказательство дистрибутивности конъюнкции (логического "и") над дизъюнкцией (логическим "или")
\((A \lor B) \land C \to (A \land C) \lor (B \land C)\)
Definition and_or_distr (A B C : Prop) : (A \/ B) /\ C -> (A /\ C) \/ (B /\ C) := fun '(conj p_a_or_b p_c) => match p_a_or_b with | or_introl p_a => or_introl (conj p_a p_c) | or_intror p_b => or_intror (conj p_b p_c) end.
Ну смысл понятен, да? Мы просто конструируем штуки всякие. Поэтому и логика такая называется "конструктивная". А как мы их конструируем? - Мы пишем функции.
Истинное высказывание
Тривиально истинное высказывание не несет никакой информации, и
его доказательство тоже должно быть тривиальным. Поэтому истинное
высказывание — это, по сути, юнит-тип во вселенной
Prop. У него ровно один конструктор с историческим именем
I.
Inductive True : Prop := | I.
Истина всегда остаётся истиной, вне зависимости от любых посылок.
Definition anything_implies_true (A : Prop) : A -> True := fun _ => I. Definition true_and_true : True /\ True := conj I I.
Ложь
Ложь это пустой тип во вселенной
Prop, у него нет конструкторов.
Inductive False : Prop := .
Так как у
False
нет конструкторов, его нельзя доказать без дополнительных
предположений — то есть в пустом контексте, если только в самом
Coq нет критических багов (а они иногда бывают). Команда
разработчиков даже держит
список таких багов.
Поскольку у
False
нет конструкторов, выражение сопоставления с образцом для него
имеет интересную форму: ветвей нет, а значит, можно построить терм
любого типа, потому что ничего определять не нужно. Это
известный принцип взрыва (ex falso quodlibet) —
из лжи следует всё. Вот пример:
Definition exfalso_quodlibet {A : Prop} : False -> A := fun (p_f : False) => match p_f with end.
Ещё простой пример:
Definition a_or_false_implies_a (A : Prop) : A \/ False -> A := fun p_a_or_f => match p_a_or_f with | or_introl p_a => p_a | or_intror p_f => exfalso_quodlibet p_f end.
Отрицание
Под
not A
(или
~ A) понимают короткую запись для
A ->
False.
Definition not (A : Prop) := A -> False. Notation "~ A" := (not A) : type_scope.
Чтобы доказать
A ->
~~A, нужно помнить, что это значит:
A -> (A -> False) -> False:Definition dni (A : Prop) : (* double negation introduction *) A -> ~ ~A := fun (p_a : A) => fun p_not_a => p_not_a p_a.
Логика, построенная таким образом, называется интуиционистской. В
ней, как известно, нельзя в общем случае доказать классический
принцип устранения двойного отрицания (DNE - double negation
elimination), то есть нельзя построить терм типа
~~A
-> A
для произвольного
A:
Всё что у нас есть это терм вот такого типа
not_not_a:
(A
-> False) ->
False. Это ф-ция, но мы её "вызвать" не можем, тк у нас нет
ни A ни
(A
-> False).
Тоже самое и с законом исключённого третьего (LEM - law of excluded middle):
Без сахара это
A \/
(A
-> False). Чтобы доказать дизъюнкцию нам нужно доказать правый либо левый
дизъюнкт, а мы не можем ни то ни другое сконструировать.
Логическая эквивалентность (двойная импликация)
Как и отрицание, логическая эквивалентность не является
first-class коннектором в Coq. Вместо этого есть определение
iff, которое означает конъюнкцию двух импликаций + нотация
<->.
Definition iff (A B : Prop) := (A -> B) /\ (B -> A). Notation "A <-> B" := (iff A B) : type_scope.
Примеры с логической эквивалентностью будут чуть позже.
Квантор всеобщности
Доказательство высказывания
\(\forall x. P(x)\) — это функция,
которая принимает какой-то конкретный
\(x\) и возвращает доказательство
\(P(x)\) и тип вот этого доказательства
зависит от
x.
Ну то есть у нас есть некий \(x\) и утверждение \(P(x)\), тип которого меняется в зависимости от \(x\). Это значит, что \(P(x_1)\) и \(P(x_2)\) имеют разные типы. Ну а это ровно то, чем является зависимая функция в теории типов.
Здесь мы не можем сами определить этот тип зависимых функций (квантор \(\forall\)), потому что это примитив Coq.
Короче вот то, что описано выше словами:
forall x : T, P x, гдеP : T -> Prop
это предикат (функция из типа в
Prop).
Пример:
Definition forall_andD (A : Type) (P Q : A -> Prop) : (forall x, P x /\ Q x) -> (forall x, P x) /\ (forall x, Q x) := fun all_p_and_q => conj (fun x => match all_p_and_q x with | conj px _ => px end) (fun x => match all_p_and_q x with | conj _ qx => qx end).
В этом примере:
-
all_p_and_q : (x : T) -> P x /\ Q x -
forall x, P x : (x : T) -> P x -
forall x, Q x : (x : T) -> Q x
Квантор существования
Квантор существования.
Это тип зависимых пар.
Конструктивно доказательство \(\exists x. P(x)\) состоит из двух компонентов:
- Конкретного элемента \(x\)
- Доказательства того, что \(P(x)\) выполняется для этого элемента \(x\)
Вот как это выглядит:
Inductive ex (A : Type) (P : A -> Prop) : Prop := | ex_intro (x : A) (proof : P x).
Получаем тип, обобщающий конъюнкцию (\(\land\)), только теперь тип второго компонента зависит от значения первого.
Упрощённая нотация, для одной переменной:
Notation "'exists' x : A , P" := (ex (fun (x : A) => P)) (at level 200, right associativity).
Общая нотация:
Notation "'exists' x .. y , p" := (ex (fun x => .. (ex (fun y => p)) ..)) (at level 200, x binder, right associativity, format "'[' 'exists' '/ ' x .. y , '/ ' p ']'") : type_scope.
Сложная нотация. Разберёмся что тут вообще происходит.
1. Нотация
exists x
y,
p
— это сокращённо от
ex (fun x
=> ex
(fun
y =>
p)), т.е. вложенное существование:
\(\exists x\),
\(\exists y\), и при этом
\(p\) истинно.
2. Команда
binder x
— позволяет коку понять, что
x это
переменная-байндер. Так он сможет распарсить такой список
переменных подряд:
x ..
y
не просто как список символов, а как цепочку связанных переменных.
3.
format
— отвечает за претти-принтинг. Позволяет нам напечатать выражение
exists x
y,
p
вот так:
[ exists x y, p ]
То есть символы
'[' и
']' это
просто визуальное оформление,
/ —
перевод строки с отступом, а сама запись становится читабельнее в
доказательствах.
Пример рассуждения с квантором существования:
Definition exists_p_x_impl_not_all_not_p_x A (P : A -> Prop) : (exists x, P x) -> ~ (forall x, ~ P x) := fun (ex_p_x : exists x, P x) => fun (not_all_not_p_x : forall x, ~ P x) => match ex_p_x with | ex_intro x p_x => not_all_not_p_x x p_x end.
Чтобы легко парсить в голове утверждения, в которых присутствует ложь, важно помнить чем является ложь в конструктивной логике:
~ A\(\equiv\)A -> False
поэтому
not_all_not_p_x
: ~
(forall
x,
~ P
x)
есть просто
not_all_not_p_x
: (forall x, P
x ->
False)
-> False
и поэтому
not_all_not_p_x
x p_x
: False
Мы ему дали конкретный \(x\) для которого выполняется \(P(x)\) и само \(P\).
Каррирование для зависимых пар:
Definition curry_dep A (P : A -> Prop) Q : ((exists x, P x) -> Q) -> (forall x, P x -> Q) := fun f : (exists x, P x) -> Q => fun x : A => fun px : P x => f (ex_intro _ x px).
-
A- это тип, поэтому его явно не надо передавать, кок его выведет -
Pявляется зависимым предикатом (функцией из элементов типаAвProp), и его можно явно передавать вex_into, либо использовать_, чтобы кок его вывел за нас
Зависимый предикат.
Это тип функции вида
P :
A ->
Prop, которая для каждого элемента
x из
типа
A
выдает утверждение
Prop
о нем.
Равенства
Одна из центральных тем теории типов. Там существует целая иерархия понятий равенства или эквивалентности.
Равенство по определению
Не известно корректный ли термин, но в оригинале это definitional equality.
На мета-уровне существует встроенное понятие равенства между термами. Оно утверждает, что любые два терма равны, если можно свести один к другому вычислениями (т.е. если они "конвертируемы"). Такое равенство нельзя доказать, такие равенства - внутренняя механика кока. То есть, насколько я понял, кок просто запускает вычисление, смотрит на редуцированную пару термов и заключает "равны" они или нет.
Пропозициональное равенство
Мы можем ввести равенство по определению внутрь
языка с помощью вот такого идуктивного типа
eq:
Inductive eq (A : Type) (x : A) : A -> Prop := | eq_refl : eq x x.
Это и называется пропозициональное равенство. Единственное свойство, заложенное в этом равенстве — рефлексивность.
Это первая встреча с индексированными типами и надо бы разобраться.
В этом определении тип
A после
двоеточия называется индексом, а
x —
параметром. Параметры должны быть одинаковыми для всех
конструкторов, а индексы могут меняться. В данном случае
конструктор один, поэтому вариантов нет. Но именно благодаря
такому механизму паттерн-матчинг позволяет симулировать равенство
между термами.
End My07.