10. Haskell. Разминка

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

Ниже будет лишь база, позволяющая нам начать пользоваться Clash как инструментом.

Шаги, которые мы проделаем.

  1. Установим Haskell (компилятор, рантайм, интеграция с IDE, все дела).

  2. Создадим базовый проект с использованием stack.

    1. Мы не будем дискутировать на тему stack vs cabal. Clash по умолчанию использует stack, так что и мы начнём с него.

  3. Напишем ряд простых функций, чтобы познакомиться с основами языка.

Полезные инструменты

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

  1. Hoogle — Haskell-специфичный поисковик. Поможет найти материалы про различные странные символы, используемые в языке (с чем обычный поисковик справится с трудом), узнать, что сообщество уже сочинило функцию, которая делает то, что нужно (в том числе просто по её типу) и т.д.

  2. Hackage — репозиторий пакетов. Если нужна какая-то функция или целый инструмент, скорее всего он есть тут, либо его ещё не придумали.

Основы

После того, как всё необходимое установлено, создадим проект по дефолтному шаблону.

Чтобы немного упростить себе жизнь, можно использовать команду stack run. Она и пересоберёт, и запустит.

Первый делом найдите Lib.hs и поменяйте выводимую строку. Перезапустите проект. Вывод поменялся? Если да, то всё отлично. Попробуем пописать новые функции.

src/10-haskell/demoproject/src/Lib.hs
module Lib
  ( someFunc, (1)
    addAndMult,
    fib,
  )
where

someFunc :: IO ()                       (2)
someFunc = putStrLn "My first print!"   (3)

addAndMult :: Num b => b -> b -> (b, b) (4)
addAndMult x y = (x + y, x * y)

fib :: Int -> Int (5)
fib 0 = 1         (6)
fib 1 = 1
fib n = fib (n - 1) + fib (n - 2) (7)
1 Указываем, какие функции будут экспортироваться из модуля.
2 Во многих случаях компилятор может сам вывеси типы, но для верхнеуровневых функций их лучше указывать. Попробуйте удалить типы и пересобрать проект — должны получить предупреждение. В данном случае функция просто совершает какой-то ввод-вывод.
3 Первым делом поменяли выводимую строку, чтобы убедиться, что всё работает и нас не обманули. Для этого приходится использовать монады. В данном случае, IO. Научиться пользоваться данным механизмом крайне желательно для понимания дальнейшего материала.
4 Наша функция может принять два элемента типа, который ведёт себя как число, и вернёт пару элементов того же типа. Для более детального понимания того, что здесь происходит рекомендуется почитать про type classes и, в частности, про class Num.
5 Тут мы вручную указали более узкий тип, чем может вывести компилятор. Это не страшно, если нас устраивает. Попробуйте убрать аннотацию и посмотреть, какой тип выведет компилятор.
6 Сопоставление с образцом для аргументов верхнеуровневых функций. Варианты просматриваются сверху вниз.
7 Все объявления рекурсивны (не надо использовать отдельные ключевые слова для обозначения рекурсивных определений). С этим надо быть аккуратным, так как иногда при рефакторинге появляются неприятные эффекты.

Для быстрого знакомства с базовым синтаксисом можно использовать вот эту статью. Также рекомендуется познакомиться со стандартной библиотекой и на всякий случай почитать как и зачем её заменять, потому что Clash как раз предоставляет свою стандартную библиотеку.

Форматирование в Haskell важно. Идея та же, что и в Python, YAML или F#, но местами более сурово и изощрённо.

Тесты, основанные на свойствах

Далее, нам важно научиться писать тесты. Для этого предлагается использовать библиотеку Hedgehog.

Попробуем создать простые тесты на написанные нами функции.

Для начала добавим соответствующий пакет к нам в проект. Для этого нужно добавить пакет в package.yaml, в раздел с зависимостями тестового подпроекта.

src/10-haskell/demoproject/package.yaml
name:                demoproject
version:             0.1.0.0
github:              "githubuser/demoproject"
license:             BSD-3-Clause
author:              "Author name here"
maintainer:          "example@example.com"
copyright:           "2024 Author name here"

extra-source-files:
- README.md
- CHANGELOG.md

# Metadata used when publishing your package
# synopsis:            Short description of your package
# category:            Web

# To avoid duplicated efforts in documentation and dealing with the
# complications of embedding Haddock markup inside cabal files, it is
# common to point users to the README.md file.
description:         Please see the README on GitHub at <https://github.com/githubuser/demoproject#readme>

dependencies:
- base >= 4.7 && < 5

ghc-options:
- -Wall
- -Wcompat
- -Widentities
- -Wincomplete-record-updates
- -Wincomplete-uni-patterns
- -Wmissing-export-lists
- -Wmissing-home-modules
- -Wpartial-fields
- -Wredundant-constraints

library:
  source-dirs: src

executables:
  demoproject-exe:
    main:                Main.hs
    source-dirs:         app
    ghc-options:
    - -threaded
    - -rtsopts
    - -with-rtsopts=-N
    dependencies:
    - demoproject

tests:                   (1)
  demoproject-test:
    main:                Spec.hs
    source-dirs:         test
    ghc-options:
    - -threaded
    - -rtsopts
    - -with-rtsopts=-N
    dependencies:
    - demoproject
    - hedgehog           (2)
1 Зависимость нужна только для тестов.
2 Добавляем имя пакета. Пока не задумываемся над версиями, просто берём актуальный.
src/10-haskell/demoproject/test/Spec.hs
{-# LANGUAGE TemplateHaskell #-} (1)

import           Hedgehog
import qualified Hedgehog.Gen    as Gen
import qualified Hedgehog.Range  as Range (2)
import           Lib                      (3)

prop_fibIsPositive :: Property (4)
prop_fibIsPositive =
  property $ do 
    x <- forAll $ Gen.int (Range.constant 0 30)  (5)
    assert $ fib x > 0                           (6)

prop_fibIsFib :: Property
prop_fibIsFib =
  property $ do
    x <- forAll $ Gen.int (Range.constant 2 30)
    fib x === fib (x - 2) + fib (x - 1) (7)

prop_fibIsMonotonic :: Property
prop_fibIsMonotonic =
  property $ do
    x <- forAll $ Gen.int (Range.constant 1 30)
    assert $ fib (x + 1) > fib x

main :: IO Bool               (8)
main =
  checkParallel $$(discover)  (9)
1 Подключим расширение Template Haskell, дающее возможности метапрограммирования времени компиляции. С данным расширением рекомендуется познакомиться поближе, так как оно достаточно активно используется в Clash. Также можно почитать про то, что такое механизм расширений в Haskell и полистать список этих самых расширений. Многие из перечисленных используются в Clash и нам ещё встретятся.
2 Загрузим пакеты, нужные для реализации тестов.
3 Загрузим тестируемую библиотеку.
4 Опишем свойство — некоторое условие, которому должна удовлетворять тестируемая функция с учётом используемых ограничений на входные данные.
Одно из замечательных свойств, которым часто пользуются при таком подходе — тестируемая функция должна вести себя так же, как некоторая другая. Часто даже для функций со сложным внутренним устройством пишут эталоны, которые ведут себя так же, но устроены существенно проще. Например, две сортировки, независимо от алгоритма, вести себя должны одинаково.
Префикс prop_ важен! По нему определяется, какие функции надо превратить в тесты.
5 Сгенерируем некоторое количество чисел от 0 до 30.
6 Проверим, что Фибоначчи "всегда" положителен.
7 Для некоторых случаев определены отдельные операторы, такие как ===. Данная строка эквивалентна
assert $ fib x == fib (x - 2) + fib (x - 1)
8 Точка входа. Обязательно main в нашем шаблоне.
9 Магическое заклинание, которое найдёт все функции, на основе которых надо сделать тесты. Как раз оно и использует Template Haskell.

Теперь в корне нашего проекта можно запустить:

stack test

Если всё сделано правильно, то получим что-то такое:

Progress 1/2: demoproject━━━ Main ━━━
  ✓ prop_fibIsPositive passed 100 tests.
  ✓ prop_fibIsFib passed 100 tests.
  ✓ prop_fibIsMonotonic passed 100 tests.
  ✓ 3 succeeded.

Попробуйте задать какое-то неправильное свойство, проверьте, что тесты не пройдут.

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