Рубрики
Без рубрики

Как начать с причиной

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

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

Большинство статей о причинах показывают, как это работает в разбуждении. Это имеет смысл, поскольку Facebook разработал причину. В этой статье, однако, я хотел показать, как причина светит в качестве языка за пределами разбавок.

Эта статья предполагает, что у вас есть базовый для промежуточного понимания JavaScript. Некоторые знакомства с функциональным программированием тоже не повредили.

Почему выбирать причину?

Причина – это функциональный язык, который поощряет неизменность, обеспечивает вылетую систему статического типа и компилирует вниз до JavaScript. Давайте приблизимся:

  1. Причина и Ocaml разделяют одну и ту же семантику. И поэтому функциональные конструирует программирование, доступные в Ocaml, такие как сопоставление шаблонов и карри, непосредственно переводят к причине.
  2. По причинам, почти всегда вам не нужно записывать типы – компилятор устанавливает для вас типы. Например, компилятор видит это знак равно > {1 + 1} как функция, которая берет u NIT (без аргументов) и возврат int.
  3. Большинство конструкций в разуме неизменны. Список неизменно. Массив сметен, но имеет фиксированный размер. Добавление нового элемента на массив возвращает копию массива, расширенного с новым элементом. Запись с ( Аналогичный на объекты JavaScript) неизменны.
  4. BescleScript Компилирует причину до JavaScript. Вы можете работать с JavaScript в коде своей причины и использовать модули причина в JavaScript.

Причина приносит преимущества сильно набранного языка к JavaScript с низкой стоимостью. Вы обязательно должны прочитать Что и Почему Раздел документации, поскольку он обеспечивает больше контекста на язык и его функции.

Некоторые ресурсы, чтобы помочь вам получить начал

  1. Официальные документы по причинам просты и до такой степени
  2. Исследующий разумный , книга доктора Акселя Раушера, исследует разум более практичным способом
  3. BescleScript Docs подробно рассказывает о совместимости с JavaScript и Ocaml

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

Большая картинка

Этот учебник был вдохновлен Расписание узлов , планировщик для Node.js, который всегда использует один таймер. Вы можете узнать больше о том, как узел расписание работает здесь Отказ

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

Для этого мы определим два модуля – куча и планировщик.

Куча – это реализация очереди приоритета. Он держит рабочие места в том порядке, что они должны быть выполнены дальше. Ключ элемента кучи – это следующее время вызова задания.

Планировщик состоит из кучи и отвечает за обновление таймера и выполнение заданий по указанным правилам рецидива.

  1. Когда работа выполняется, планировщик удалит задание из очереди, рассчитывает его следующее время вызова и вставляет задание обратно в очередь с обновленным временем вызова.
  2. Когда добавлена новая задача, планировщик проверяет следующее время вызова корня (голова/задание, которое будет выполнено следующим). Если новая задача должна быть выполнена перед головой, планировщик обновляет таймер.

Модуль кучи

АПИ очереди приоритета определяет:

  1. Вставка нового элемента в очередь с ключом, представляющим его приоритет
  2. Извлечение элемента с наивысшим приоритетом
  3. Размер очереди

Куча выполняет вставлять и Экстракт Операции в порядке O (log (n)) где n это размер очереди.

Примечание. Мы будем говорить о сложности алгоритма в последнем разделе статьи. Если вам не комфортно с сложностью алгоритма, вы можете игнорировать последний раздел.

Если вам не комфортно с структурой данных кучи или нужна ревью, я рекомендую смотреть следующую лекцию от MIT OCW 6006 курс Отказ В остальном этом разделе мы реализуем псевдокод, изложенные в лекционные ноты 6006.

Определение типов, используемых модулем кучи

Heapelement Определяет Запись тип. Подобно объекту JavaScript, вы можете получить доступ к полям записи по имени. {ключ: 1, значение: «1»} создает значение типа Heafelement (INT, String) Отказ

T ('A,' B) это еще один тип записи и представляет собой кучу. Это возвратный тип нашего Создать Функция и последний параметр передан всем другим функциям в публичном API нашего модуля кучи.

Для поддержания свойства Max Heap Heap необходимо только для сравнения ключей элементов в массиве. Следовательно, мы можем скрыть тип ключа от кучи, предоставив функцию сравнения Сравнить Это возвращает true, когда его первый аргумент имеет более высокий приоритет, чем второй.

Это первый раз, когда мы видим Ref Отказ Ref это причина для поддержки мутации . Вы можете иметь Ref к значению и обновить, что Ref Чтобы указать на новое значение, используя : = оператор.

Массивы По причинам смещаются – вы можете обновить значение по конкретному индексу. Однако у них есть фиксированная длина. Для поддержки добавления и добычи наша куча должна удержать на Ref на массив элементов кучи. Если мы не будем использовать ссылку здесь, мы получим необходимость вернуть новую кучу после каждого дополнения и извлечения. И модули, которые зависят от кучи, должны отслеживать новую кучу.

Исключение Может быть продлен с новыми конструкторами. Мы будем поднять Stristrueue Исключение позже в извлекать и голова Функции в модуле кучи.

Подпись

По умолчанию все привязки (переменные назначения) в модуль доступны везде даже за пределами модуля, где они определены. Подпись Является ли механизм, посредством которого вы можете скрыть конкретную логику внедрения и определить API для модуля. Вы можете определить подпись в файле с тем же именем, что и модуль, заканчивающийся. Рей суффикс. Например, вы можете определить подпись для Heap.re в Heap.rei файл.

Здесь мы подвергаем определение надоедать Таким образом, пользователи модуля кучи могут использовать значение, возвращаемое голова и Экстракт Отказ Но мы не предоставляем определение для T наш тип кучи. Это делает T Абстрактный тип Что гарантирует, что только функции в модуле кучи могут потреблять кучу и трансформировать ее.

Каждая функция, кроме Создать берет как аргумент куча. Создать принимает функцию сравнения и создает пустую Heap.t которые могут потребляться другими функциями в модуле кучи.

Функции помощника

родитель это функция, которая принимает один аргумент – индекс. Это возвращает Нет Когда индекс – 0 Отказ Индекс 0 Указывает корень дерева, и корня дерева не имеет родителей.

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

своп занимает два индекса А и B и массив очередь . Затем он сворачивает значения в индексе А а также B из очередь Отказ

ключ Просто возвращает ключевое поле Heapelement по указанному индексу в очереди.

Размер Возвращает длину очереди

Добавлять

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

Пусть Rec давайте определим рекурсивный Функции. С реконструкция Вы можете обратиться к имени функции внутри корпуса функции.

Мы определили ключ как функция, которая берет очередь и Индекс как аргументы. С декларацией Пусть (очередь) Мы Затенение ключ по частично применяя Функция помощника ключ Мы определены ранее.

Когда вы предоставляете подмножество аргументов функции, он возвращает новую функцию, которая принимает оставшиеся аргументы в качестве ввода – это известно как Carrying Отказ

Представленные вами аргументы доступны для возвращенной функции. С очередь фиксируется в починить мы частично применим его к ключ Функция, чтобы сделать наш код больше СУХОЙ .

Вы можете использовать E> ; когда Opdition> Указать дополнительные условия в сопоставлении шаблонов. Значение связано ons В этом случае доступны для Expressio n fo будущее, когда (в наше е Rampple PIND доступен в сравнении (ключ (индекс), ключ (PIND)). Только когда условие удовлетворено, мы выполняем соответствующие статумы T после =>.

Добавить объединяет новый элемент до конца очереди. Если новый элемент имеет более высокий приоритет, чем его родитель, он нарушает свойство Max Heap. fix_up Это рекурсивная функция, которая восстанавливает свойство Max Heap, переместив новый элемент в дереве (попарно подключится со своим родителем) до тех пор, пока он не достигнет корня дерева или его приоритет, ниже его родителя.

fix_last просто обертка вокруг fix_up и называет это с индексом последнего элемента в очереди.

Heap.queue ^ Как мы получим доступ к значению Ref использованная литература.

[ ]

Извлекать

Экстракт Удаляет элемент с наивысшим приоритетом (в нашем случае элемент с наименьшим ключом) из очереди и возвращает его. Экстракт Удаляет голову очереди, сначала обменивалось его последним элементом в массиве. Это вводит одно нарушение недвижимости Макса кучи в корне/главе очереди.

Как описано в лекции, HeaPify – Также известен как SIFT-Down – исправляет одно нарушение. Предполагая левые и правые поддеревы узла n Удовлетворить недвижимость Макс Куча, позвонив HeaPify на n исправляет нарушение.

Каждый раз HeaPify называется, он находит max_priority_index Индекс наивысшего приоритета элемента между эмблемыми на показатель , слева (индекс) и Правильно (индекс) Отказ Если max_priority_index не равно Индекс Мы знаем, что все еще есть нарушение недвижимости Макса Куча. Мы поменяем элементы на Индекс и max_priority_index исправить нарушение на Индекс Отказ Мы рекурсивно звоним HeaPify с max_priority_index Чтобы исправить возможное нарушение, которое мы могли бы создать, заменяя два элемента.

Индекс это int Представляя корень поддерева, который нарушает свойство Max Heap, но его поддеревцы удовлетворяют имуществу. Сравнить Функция сравнения определяется кучей. очередь это массив, который удерживает элементы кучи.

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

Экстракт шаблон матчей против очередь (Массив не ссылается).

[| голова |] Соответствует только массиву одним элементом.

Когда очередь пуста [ ] Мы поднимаем Stristrueue Исключение, которое мы определены ранее. Но почему? Почему бы нам не вернуться Нет вместо? Ну, это вопрос предпочтений. Я предпочитаю поднять Исключение, потому что когда я использую эту функцию, я получу Heapelement а не Вариант (Heapelement) . Это сохраняет мне сопоставление шаблона против возвращаемого значения Экстракт Отказ Предостережение заключается в том, что вы должны быть осторожны, когда вы используете эту функцию, убедитесь, что очередь

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

Тестирование

Мы используем BS-jest – Привязки BescleScript для Jest – написать тесты. Jest Является ли структура тестирования Facebook, которая поставляется со встроенными издевателями библиотеки и отчетами о покрытии кода.

  1. https://github.com/glennsl/bs-jest
  2. https://facebook.github.io/jest/docs/en/getting-started.html

Следуйте инструкциям в BS-jest настроить Jest Отказ

Убедитесь, что добавить @ glennsl/bs-jest к BS-dev-зависимости В вашем bsconfig.json Отказ В противном случае BuckleScript не найдет Шума Модуль и ваша сборка потерпит неудачу.

Если вы пишете свои тестовые случаи в каталоге, отличном от Src. Вы должны указать это в Источники в bsconfig.json для компилятора BescleScript, чтобы забрать их.

Тестирование синхронных функций

С Куча Модуль на месте и Jest Установлено, мы готовы написать наш первый тестовый случай.

Чтобы проверить наше Куча Модуль, мы сделаем сортировку кучи.

  1. создать кучу
  2. Вставьте элементы в кучу
  3. Используйте Экстракт Операция для удаления элементов в порядке возрастания

открыть шутку Открывает модуль, чтобы мы могли ссылаться на привязки, доступные в Jest модуль, не подготовив их к Jest. . Например, вместо написания Jest.expect. Мы можем просто написать ожидать Отказ

Мы используем Пусть {Значение: E1} = разрушать значение, возвращенное Экстракт и создать псевдоним E1 для ценностьE1 теперь связан с ценность Поле значения, возвращаемого Экстракт Отказ

С | & g t; Труба Opera TOR Мы можем создать композитный функцию и применить результирующую функцию немедленно на входе. Здесь мы просто проходим результат Calli NG EXP т.к. вал TH (E1, ..., E9) до t Он Ток функция UAL.

Модуль планировщика

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

Давайте определим типы, используемые в модуле планировщика

Рецидив это Вариант тип. Любое значение Рецидив Тип может быть либо Второй , Минут или Час . Второй , Минута и Час являются конструкторами для Рецидив Отказ Вы можете вызвать конструктор как нормальную функцию и вернуть значение типа варианта. В нашем случае, если вы звоните Второй С int вы вернете значение типа Рецидив Отказ Вы можете обратно сопоставить это значение с Второй (Number_of_seconds) Чтобы получить доступ к аргументу, который был передан на Второй конструктор.

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

T это тип записи, представляющий планировщик. Планировщик держит на очередь рабочих мест, отсортированных по следующему времени в вызове. Timer_id Ссылки на Тимрид Для первой работы в очередь – Работа, которая будет с толку первой.

Взаимодействовать

Вы можете вызвать функции JavaScript изнутри причины. Есть разные способы сделать это:

  1. Вы можете использовать привязки BescleScript, если доступны, такие как Js.log и Js. Global.settimeout.
  2. Объявить внешний такие как [@ Bs.Val] Внешний сертитный
  3. Выполнить RAW JavaScript Code с [%сырой ...]

Привязки для большинства функций JavaScript предоставляются BescleScript. Например, Js. Date.gettime берет Js. Дата – А Дата Значение – и возвращает количество миллисекунд с эпохи. Js. Date.gettime Является ли связывание для получить время Метод объекта дата JavaScript. Js. Date.gettime Возвращает плавать ценить.

Использование привязки BrackLescript точно так же, как с использованием пользовательских модулей. Вы можете прочитать больше о доступных привязоках здесь Отказ Для остальной части этого раздела мы сосредоточимся на внешний и [%сырой ...] .

внешний

С внешний Вы можете связать переменную к функции JavaScript. Здесь, например, мы обязательно Setimeate Переменная для Global функций Setimeout JavaScript.

Setimeate Возвращает плавать , идентификатор, который мы можем передать на Clearimeout. отменить таймер. Единственная функция, которая использует значение, возвращаемое Setimeate это Clearimeout. . Таким образом, мы можем определить значение, возвращенное совокупность иметь Абстрактный тип Отказ Это гарантирует, что только значение, возвращенное совокупность может быть передано на Clearimeout Отказ

[%сырой …]

Новая дата. GDETTIME () В JavaScript возвращает целочисленное число. Числа в JavaScript длится 64 бит . int В разухе только 32-бит . Это проблема!

По причинам, мы можем работать с возвращенным значением Новая дата. GDETTIME () Ожидая, что это будет Плавать Отказ Это на самом деле ожидаемый тип возврата Js. Date.gettime предоставляется BescleScript.

Вместо этого давайте использовать [% RAW ...] и создать абстрактный тип долго Подобно тому, что мы сделали для Setimeate Отказ При этом мы скрываем реализацию длинный . Наша причина кода может передавать значения типа долго вокруг, но на них не может работать. Для этого мы определяем набор виндерных привязки, которые принимают значения типа долго и делегировать вычисление на сырые выражения JavaScript.

Мы можем определить выражение JavaScript с [%сырой …] . Здесь мы определяем Абстрактный тип длинный и набор функций, которые потребляют и возвращает значения типа длинный . Тип всех выражений указан в Пусть привязки.

time_now Возвращает количество миллисекунд с эпохи.

Мы используем сумма рассчитать следующее время вызова работы, пройдя в результате time_now и int Представляя, сколько миллисекунд от сейчас должна быть выполнена работа.

Мы можем вычислить, как долго с данной задача будет вызвана Вычитание Время вызова работы от time_now Отказ Результат Вычитание передается на Setimeate Отказ

has_igher_priority сравнивает два времени вызова. Это функция сравнения, которую мы используем для инициализации нашей кучи.

Вызов

В любой момент времени у нас есть только один таймер, который истекает только при запуске первой работы в очереди. Когда истекает таймер, нам нужно сделать некоторую очистку. Когда истекает таймер, мы должен

  1. извлечь первую работу из очереди
  2. Рассчитайте свое следующее время вызова (новый ключ для работы)
  3. Вставьте задание обратно в очередь с обновленным ключом
  4. посмотрите на голову очереди, чтобы найти работу, которая должна быть выполнена рядом и
  5. создать новый таймер для этой работы

ждать занимает период – значение типа Рецидив – И возвращает int, представляющий, сколько миллиметровых секунд должно подождать работу, прежде чем снова выполняться. Мы проходим значение, возвращенную ждать к Setimeate Отказ

next_invocation рассчитывает следующее время вызова работы. time_now Возвращает долго ценить. сумма берет в долго и int Значение и возвращает долго ценить. сумма Добавляет два номера, вызывая JavaScript +. Оператор на его аргументах.

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

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

Затем мы продолжим создание нового таймера для работы во главе очереди (следующая задача, которая должна быть выполнена после этого вызова). Мы обновляем Timer_id Ссылка на точку на новый Тимрид Отказ

Наконец, мы называем вызывать поле работы, чтобы выполнить указанную задачу.

Добавить новую работу

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

Чем более интересный случай – когда очередь не пуста! У нас есть две ситуации здесь. Либо головка очередь имеет ключ больше, чем следующее время вызова работы или нет.

Первый случай – когда глава очередь имеет ключ, меньше или равный следующему времени вызова работы. Это тот случай, когда новая задача должна быть выполнена перед текущим таймером. В этом случае нам нужно отменить таймер, позвонив Clearimeout с Timer_id и создать новый таймер, который истекает в следующем времени вызова новой работы.

В другом случае, потому что новая работа должна быть выполнена после истечения срока действия текущего тайма, мы можем просто вставить новую работу в очередь Отказ

Тестирование асинхронных функций

Все функции в модуле кучи – Синхронный Отказ Например, когда вы звоните Добавить , вы заблокированы до тех пор, пока в очередь не будет добавлено новое доходение. Когда Добавить Возвращает, вы знаете, что куча была расширена новым элементом.

Функции в планировщике, с другой стороны, есть асинхронный побочные эффекты. Когда ты Добавить Новая работа в планировщик, планировщик добавляет задание на очередь и возвращает. Позже, согласно Рецидив Правило задания вызывается. Ваш код не ждет, пока работа будет вызвать, и продолжает выполнять.

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

Сделать это мы будем

  1. Добавить Работа для планировщика должна выполняться каждую секунду. Эта работа увеличивает Ref (int) прилавок.
  2. Создать Обещание это разрешено после 4s
  3. вернуть Jest.assertion Обещание, которое ожидает, что счетчик был увеличен 4 раза.

Мы можем использовать TestPromise проверить обещания. TestPromise ожидает, что Js.promise.t (jest.assertion) . Посмотрите на последнюю строку тестового корпуса.

Планировщик. Осекона (1) Указывает, что мы хотим, чтобы наша работа выполнить каждую секунду.

счетчик это Ref и каждый раз вызывать называется, он увеличивается.

Обещание это Js. Обещание Это будет решено после 4 с. Обратите внимание, что мы ждем 4.1, чтобы убедиться, что последний звонок для вызывать закончил выполнение. В противном случае мы могли бы решить обещание, когда мы только увеличили счетчик три раза.

Вы можете использовать | & GT; цепочка обещаний. В нашем экзамене E, PROM ISE будет разрешаться со значением счетчика после 4S. Это значение предоставляется как T он CO функцию, передаваемую t Он js.promise en_.

Оптимизировать

Мы реализовали наши модули кучи и планировщика, аналогичные, что мы бы сделали в JavaScript. При этом мы сократили производительность функций, работающих на куче, например Добавить и Экстракт к O (n) Отказ

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

Я создал версию кучи и динамического массива, если вы заинтересованы в реализация Однако я думаю, что это будет за пределами объема этой статьи. Поэтому на данный момент мы сосредоточены на оптимизации планировщика, позвонив операции, которые стоили O (n) менее часто.

В планировщике есть два места, где мы называем Heap.add и Heap.extract. – при добавлении новой работы и при выполнении задания.

Мы не можем помочь Strulener.add.add. Но мы можем исправить производительность Планируемый .execute Отказ Выполнить Функция не нужно звонить извлекать или Добавить Поскольку размер нашей очереди до и после Выполнить должно быть то же самое.

Давайте введем новую функцию для нашей подписи кучи. underrese_root_priority Уменьшает приоритет корня кучи. Мы можем использовать эту новую функцию для обновления корневой клавиши к следующему времени вызова, не сначала извлекивая головку очереди и добавив его обратно с обновленным временем вызова.

underrese_root_priority Занимает новый приоритет для корня, проверяет, что новый приоритет меньше текущего приоритета root, и делегирует фактическую работу в функцию помощника update_priority Отказ

update_priority может уменьшить или увеличить приоритет любого элемента в куче в O (log (n)) . Он проверяет, нарушает ли новый приоритетную собственность на недвижимость Макс-Куча в отношении детей узла или его родителя. Когда мы увеличиваем приоритет узла, мы могли бы нарушить свойство узела Max Heap в отношении своего родителя и поэтому мы fix_up Отказ Когда мы уменьшаем приоритет узла, мы можем нарушить недвижимость Макс Куча по отношению к своим детям И поэтому мы называем HeaPify исправить возможное нарушение.

Следующие шаги

Эта статья не является полным обзором особенностей причины. Мы видели много языковых конструкций, но не изучали их подробно. Есть также функции, которые были оставлены, такие как функторы и объекты. Я настоятельно рекомендую вам прочитать Документация или Исследующий разумный и функциональное программирование Чтобы узнать, что доступно для вас, прежде чем прыгать в кодирование.

Полный исходный код для того, что мы охватываем сегодня, доступно в Мастер ветвь https://github.com/artris/reason-scheduler

Если вы хотите попрактиковаться, я призываю вас добавить Удалить Функциональность для планировщика. В специфике продлить подпись Планировщик с участием

  • Тип Jobid а также
  • Пусть удалить = (t, jobid) = > ты отрицательный

Я также рекомендую вам добавлять тестовые случаи для функций, выставленных в подписи Куча и Планировщик модули.

Тестовые случаи для всех функций в Куча и Планировщик Модуль, а также реализация для Удалить Функциональность доступна в Решения ветвь.

Атрибуция

Я хотел бы поблагодарить причину/BescleScript для предоставления подробной документации. И доктор Axel Rauschmayer для Исследующий разумный Книга и многие интересные статьи по разуму.

Кодовые фрагменты были созданы с использованием carbon.now.sh Отказ

Я также хотел бы поблагодарить Грейс , Сами , Фриман и PreeTPal Кто помог просмотреть эту статью.

Оригинал: “https://www.freecodecamp.org/news/how-to-get-started-with-reason-cef7ab40660/”