6 парадигм программирования, которые изменят ваше мнение о коде
Время от времени Yevgeniy Brikman находит новые для него языки программирования. И каждый раз ему приходится менять свое мнение о коде.
В этой статье он делится 6 языками, которые стали для него открытием.
1. Параллельность по умолчанию
На сегодняшний день существует огромное количество языков программирования. Но лишь немногие могут выполнять вычисления параллельно. Даже сложно представить, что всякая линия в коде выполняется параллельно остальным.
Представим, что у нас есть 3 строки кода: A, B и C.
A; B; C;
В большей части языков А будет выполнена первой, за ней В и только в конце С. Однако в языках, где параллельность вычислений определена по умолчанию все линии кода выполнятся одновременно.
Рассмотрим пример в ANI. Любая программа, которая написана на нем, состоит из "труб" и "задвижек". Они нужны для того, чтобы управлять потоками. Нетипичный синтаксис довольно тяжело понять и кажется, что язык мертв, но тем не менее его концепции довольно интересны.
Вот так выглядит "Hello, World!" в ANI:
"Hello, World!" ->std.out
В ANI наша строка "Hello, World!" является объектом и передается потоку std.out. Но что произойдет, когда мы отправим туда еще один объект?
"Hello, World!" ->std.out "Goodbye, World!" ->std.out
Так как весь код выполняется в одно и то же время, наши строки появятся в консоли в неизвестном порядке. А следующий пример покажет, что будет, если мы сначала введем переменную, а после создадим на нее ссылку:
s = [string\]; "Hello, World!" ->s; \s ->std.out;
"Задвижка" - это переменная в ANI. В нашем случае задвижка это s, содержащая строку. Дальше мы передаем ей текст "Hello, World!". А в третьей линии кода "задвижка" снимается, и её содержимое отправляется в std.out. Этот пример демонстрирует неявную последовательность в созданной программе. Такое происходит тогда, когда каждая следующая строка зависит от предыдущей.
В Plaid параллельные вычисления определены по дефолту, но при этом здесь используется модель разрешения для настройки control flow. В Plaid изучаются и другие концепты, как например Typestate-Oriented программирование. Согласно ему, объектом становится изменение состояния. Таким образом вы определяете объекты не как экземпляр класса, но как переходы из одного состояния в другое. Впоследствии они будут проверены компилятором.
Многоядерность - тренд нынешнего времени, а вот параллельные вычисления в большем количестве языков остаются сложнее, чем они должны быть. ANI и Plaid предлагают новый подход к проблеме, который может значительно улучшить производительность. Другой вопрос заключается в том, сделает ли "параллельность по дефолту" эту самую параллельность легче или сложнее?
2. Зависимые типы
Система типов в самых популярных языках, таких как С и Java, является наиболее привычной для огромного количество разработчиков. В ней компилятор может проверить, является ли ваша переменная integer, list, или string. Но только представьте, что он смог бы проверить, что созданная вами переменная - это integer больше 0, list длиной = 2 или string-палиндром.
Именно это и легло в основу языков, которые поддерживают зависимые типы. Их преимущество в том, что значение переменных можно проверить пока компилируется код. Для Scala существует библиотека Shapeless. Она добавляет неполную экспериментальную поддержку для зависимых типов. Также эта библиотека снабжена некоторым количеством наглядных примеров.
В приведенном далее примере можно посмотреть, как происходит объявление вектора со значениями 1, 2 и 3.
val l1 = 1 :#: 2 :#: 3 :#: VNil
В этом примере создается переменная l1. Согласно типовой сигнатуре мы определяем, что это не просто вектор, а вектор, который состоит из целых чисел, и имеющий длину = 3. Данная информация может быть использована компилятором для отлова ошибок в коде. Чтобы сложить два вектора, мы попробуем использовать метод vAdd в Vector:
val l1 = 1 :#: 2 :#: 3 :#: VNil val l2 = 1 :#: 2 :#: 3 :#: VNil val l3 = l1 vAdd l2 // Результат: l3 = 2 :#: 4 :#: 6 :#: VNil
Пример выше работает отлично, потому что система типов знает, что оба вектора одинаковы по длине. Однако, если мы попробуем сложить два вектора, которые отличаются по длине, то получим ошибку во время компиляции.
val l1 = 1 :#: 2 :#: 3 :#: VNil val l2 = 1 :#: 2 :#: VNil val l3 = l1 vAdd l2 // Результат: ошибка компиляции, так как нельзя складывать векторы разной длины
Shapeless - это потрясающая библиотека, но судя по тому, что мы видели, она немного груба и поддерживает лишь подмножество зависимых типов, что приводит к подробным подписям в коде. С другой стороны, Idris делает типы объектами первого класса, так что система зависимых типов выглядит чище и мощнее. Для сравнения, можете посмотреть Scala vs Idris.
Формальные методы проверки созданы для типа long, но часто чересчур массивны, чтобы их можно было использовать для программирования общего назначения.
Зависимые типы на языках, подобных Idris, и, возможно, даже Scala в скором будущем смогут предложить более легкие и практичные альтернативы, которые увеличат мощность системы типов при отлове ошибок и исключений.
Очевидно, что ни одна система зависимого типа не сможет обнаружить абсолютно все ошибки из-за нецелевых ограничений от проблемы остановки, но, если все будет хорошо, то зависимые типы станут следующим большим скачком для статических систем.
3. Конкатенативные языки
Думали ли вы как будет выглядеть программа без переменных и функций? Нет? Автор тоже. Однако некоторые разработчики, поразмышляв на эту тему, пришли к конкатенативному программированию. Суть состоит в том, что все в этом языке - это функция, которая помещает информацию в стек, либо извлекает ее оттуда; программы почти полностью созданы с использованием композиции функций. (конкатенация = композиция)
Чтобы это не было совсем абстрактно, давайте рассмотрим простой пример на cat:
2 3 +
В этом примере в стек помещаются два числа, а после вызывается функция +, которая выталкивает эти значения из него и выдает результат, который является их суммой. Это значение опять помещается в стек. Вот еще один пример:
def foo { 10 < [ 0 ] [ 42 ] if } 20 foo
Давайте разберем каждую строку:
- 1. Во-первых объявим функцию foo. Стоит отметить, что в cat не нужно определять параметры, которые принимает функция. Они будут неявно считаны из стека.
- 2. foo вызывает метод<, который вытаскивает первый элемент из стека, сравнивает его с 10 и толкает либо True, либо False назад в стек.
- 3. Затем мы помещаем 0 и 42 в стек: чтобы они попали туда без вычислений, их нужно поместить в скобки. Это связано с тем, что они используются как ветви «then» и «else» для вызова функции if в 4 линии кода.
- 4. Функция if выталкивает 3 элемента из стека: логический оператор, ответвления «then» и «else». В зависимости от значения логического условия он помещает результат либо к «then», либо «else» обратно в стек.
- 5. Наконец, мы помещаем 20 в стек и вызываем функцию foo.
- 6. В результате мы получим число 42.
Подобный стиль программирования имеет несколько интересных качеств: программы могут быть разделены и объединены бесчисленными способами для создания новых программ; чрезвычайно минимальный синтаксис (даже более минимальный, чем LISP), что приводит к очень сжатым программам; сильная поддержка метапрограмм. По мнению разработчика, конкатенативное программирование представляет собой эксперимент с мысленным открытием, но он предпочитает не поддаваться его практичности. Все выглядит так, что кажется, что вам нужно запомнить или представить данное состояние стека вместо того, чтобы читать его по названиям переменных в коде, что может затруднить рассуждение о коде.
4.Декларативное программирование
Декларативное программирование существует довольно давно, однако многие разработчики до сих пор не знают об этом концепте. Его суть состоит в том, что вам нужно просто описать результат, который вы хотите получить, а язык самостоятельно определит, как это сделать.
Например, если вы пишете какой-либо алгоритм сортировки в С, то вам нужно шаг за шагом описать каким образом она должна происходить. Если же вы хотите отсортировать числа в декларативном языке, например, таком как Prolog, то вам нужно всего лишь описать желаемый вывод. Например, "мне нужен список значений, где число с индексом i меньше или равно числа с индексом i+1.
sort_list(Input, Output) :- permutation(Input, Output), check_order(Output). check_order([]). check_order([Head]). check_order([First, Second | Tail]) :- First =< Second, check_order([Second | Tail]).
Если вы работали с SQL, то некое представление о том, как работает декларативное программирование, у вас уже есть. Во время запроса к базе данных, вы по сути пишете, что хотите получить в результате. То как будет происходить извлечение данных - не ваша забота. В случае, если вы захотите посмотреть, что же все-таки произошло под "капотом", можно использовать таблицу объяснения.
Такой вид программирования позволяет работать на более высоком уровне абстракции. Поэтому ваша работа заключается лишь в том, чтобы описать желаемый результат. Например, в коде простого решателя судоку на Prolog просто нужно перечислить каким образом должны выглядеть каждая строка и колонка, и диагональ решенного пазла.
sudoku(Puzzle, Solution) :- Solution = Puzzle, Puzzle = [S11, S12, S13, S14, S21, S22, S23, S24, S31, S32, S33, S34, S41, S42, S43, S44], fd_domain(Solution, 1, 4), Row1 = [S11, S12, S13, S14], Row2 = [S21, S22, S23, S24], Row3 = [S31, S32, S33, S34], Row4 = [S41, S42, S43, S44], Col1 = [S11, S21, S31, S41], Col2 = [S12, S22, S32, S42], Col3 = [S13, S23, S33, S43], Col4 = [S14, S24, S34, S44], Square1 = [S11, S12, S21, S22], Square2 = [S13, S14, S23, S24], Square3 = [S31, S32, S41, S42], Square4 = [S33, S34, S43, S44], valid([Row1, Row2, Row3, Row4, Col1, Col2, Col3, Col4, Square1, Square2, Square3, Square4]). valid([]). valid([Head | Tail]) :- fd_all_different(Head), valid(Tail).
Вот как вы бы запустили код, приведенный выше:
| ?- sudoku([_, _, 2, 3, _, _, _, _, _, _, _, _, 3, 4, _, _], Solution). S = [4,1,2,3,2,3,4,1,1,2,3,4,3,4,1,2]
Недостатком, к сожалению, является то, что такие языки программирования могут ударить по слабым местам производительности. Производительность алгоритма в примере - O (n!); к тому же поиск в решателе судоку происходит благодаря грубой силе и поэтому большинству программистов пришлось бы предоставить подсказки в базе данных и дополнительные индексы, чтобы не делать его дорогостоящим и неэффективным.
5. Символьное программирование
Примеры: Aurora
Aurora – пример символьного кодирования. Подобные языки позволяют писать код не только простым текстом, но также использовать для этого изображения, математические уравнения, графики и прочее. Благодаря этому появляется возможность управлять и описывать большое количество информации в "родном" формате. Aurora - интерактивный язык, он показывает результат каждой строки немедленно, словно REPL на стероидах.
https://youtu.be/L6iUm_Cqx2s
Крис Гранжер - создатель языка, создал еще и Light Table IDE.
6. Программирование, основанное на знаниях
Примеры: Wolfram Language
В Wolfram, как и в Aurora используется программирование с помощью символов. Однако здесь такой способ кодирования - лишь слой, который помогает обеспечить согласованный интерфейс к ядру языка. В действительности базой этого языка программирования служат знания, которые потребляются из невероятного количества библиотек, алгоритмов и прочих видов информации. Благодаря этому потоку данных, Wolfram с легкостью выполняет самые разные задачи. Например, с его использованием можно создать графики на основе связей в Facebook, работать с изображениями, смотреть прогноз погоды, обрабатывать запросы на нативном языке, строить маршруты на карте и даже решать уравнения.
https://youtu.be/_P9HqHVPeik
По мнению автора статьи, Wolfram обладает невероятным потенциалом, а все из-за огромнейшей "стандартной библиотеки" и набора данных, который больше чем в любом другом существующем языке. Еще одна "фишка" языка в том, что для работы с ним необходимо подключение к сети Интернет и поэтому все работает почти как в IDE, а автозаполнение здесь основано на поиске Google. По этой причине перспективы развития Wolfram очень интересны.