≪ Today I learned. RSS購読
公開日
更新日
タグ
Haskell , TypeScript
著者
ダーシノ(@bc_rikko)

フロントエンドエンジニアのためのHaskell入門

JavaScriptのなんちゃって関数型プログラミングではなく、純粋な関数型プログラミングのエッセンスを学びたくてHaskellを選んだ。しかし、過去3回入門したが3回破門されたので、今度こそ免許皆伝したい。

普段はTypeScriptで開発を行っているので、Haskellと比較しながら学んでいきたい。

基本構文

変数の宣言

const x: number = 10
x :: Int
x = 10

関数の宣言

function add(x: number, y: number): number {
  return x + y
}
add :: Int -> Int -> Int
add x y = x + y

アロー関数

const add = (x: number, y: number): number => x + y
add :: Int -> Int -> Int
add = \x y -> x + y

リスト

const list: number[] = [1, 2, 3]
list :: [Int]
list = [1, 2, 3]

タプル

const tuple: [number, string] = [10, "hello"]
tuple :: (Int, String)
tuple = (10, "hello")

条件分岐

if (x > 10) {
  return 'big'
} else {
  return 'small'
}
if x > 10
  then "big"
  else "small"

パターンマッチ

switch(x) {
  case: 1
    return 'one'
  case: 2
    return 'two'
  default:
    return 'other'
}
case x of
  1 -> "one"
  2 -> "two"
  _ -> "other"

ループ(for)

for (let i = 0; i < 10; i++) {
  console.log(i)
}
map print [0..9]

ループ(while)

while(isDone === false) {
  console.log('not done')
}

Haskellにはループがないので、再帰や高階関数を使う。

オブジェクト

type Person = {
  name: string
  age: number
}

const person: Person = {
  name: 'Alice',
  age: 20
}
data Person = Person { name :: String, age :: Int }
person = Person { name = "Alice", age = 20 }

サンプルプログラム(ToDoリスト)

TypeScript

ソースコード
type Task = {
  taskID: number
  title: string
}

function listTask(taskRef: Task[]) {
  console.log('Task List:')

  if (taskRef.length === 0) {
    console.log('No tasks.')
  } else {
    taskRef.forEach(({ taskID, title }) => {
      console.log(`${taskID}. ${title}`)
    })
  }
}

function addTask(taskRef: Task[], title: string) {
  const newTask = {
    taskID: taskRef.length + 1,
    title
  }

  taskRef.push(newTask)
  console.log('Task added:', newTask)
}

function removeTask(taskRef: Task[], targetID: number) {
  const newTasks = taskRef.filter(({ taskID }) => taskID !== targetID)

  if (taskRef.length === newTasks.length) {
    console.log('Task not found.')
  } else {
    taskRef.splice(0, taskRef.length, ...newTasks)
    console.log('Task removed:', targetID)
  }
}

function main(): void {
  const tasks: Task[] = [];

  console.log("=== Step 1: Add Tasks ===");
  addTask(tasks, "Buy broccoli");
  addTask(tasks, "Walk the dog");
  addTask(tasks, "Study Haskell");

  console.log("\n=== Step 2: List Tasks ===");
  listTasks(tasks);

  console.log("\n=== Step 3: Remove Task ===");
  removeTask(tasks, 2);

  console.log("\n=== Step 4: List Tasks After Removal ===");
  listTasks(tasks);

  console.log("\n=== Done! ===");
}

main()

Haskell

-- ミュータブルなデータを扱うためのモジュール
import Data.IORef

{-
  Taskのデータ型
-}
-- deriving Show はTask型を文字列に変換するための関数を自動生成する
data Task = Task {
  taskID :: Int,
  title :: String
} deriving Show

{-
  Task一覧の表示
-}
listTasks :: IORef [Task] -> IO ()
listTasks tasksRef = do
  -- 引数tasksRefからデータを読み取る
  tasks <- readIORef tasksRef
  putStrLn "Task List:"

  if null tasks
    -- tasks === null なら No tasks.
    then putStrLn "No tasks."
    -- Array#map()と同じで無名関数を作っている
    -- (Task i title)は引数:tasksからtitleを取得する
    else mapM_ (\(Task taskID title) -> 
      putStrLn $ show taskID ++ ". " ++ title
    ) tasks

{-
  Taskの追加
-}
addTask :: IORef [Task] -> String -> IO ()
addTask tasksRef title = do
  tasks <- readIORef tasksRef

  -- 引数titleを使って新しいTaskを作成する
  let newTask = Task {
    taskID = length tasks + 1,
    title = title
  }

  -- modifyIORefはIORefに格納された値を更新するために仕様される
  -- (++ [newTask])の++はリスト結合の演算子で、tasksRefの末尾にnewTaskを追加する
  modifyIORef tasksRef (++ [newTask])

  -- putStrLnの引数内で文字結合できないため$をつける
  putStrLn $ "Task added:" ++ show newTask 

{-
  Taskの削除
-}
removeTask :: IORef [Task] -> Int -> IO ()
removeTask tasksRef targetID = do
  tasks <- readIORef tasksRef

  -- tasks.filter(({ taskID }) => taskID !== targetID) と同じ
  let newTasks = filter (\(Task taskID _) -> taskID /= targetID) tasks

  if length tasks == length newTasks
    then putStrLn "Task not found."
    else do
      -- writeIORefはIORefに新しいTaskリストを書き込む
      writeIORef tasksRef newTasks
      putStrLn $ "Task removed." ++ show targetID

{-
  エントリーポイント
-}
main :: IO ()
main = do
  -- メモリ上にTaskリストを作成する
  tasksRef <- newIORef []

  putStrLn "=== Step 1: Add Tasks ==="
  addTask tasksRef "Buy broccoli"
  addTask tasksRef "Walk the dog"
  addTask tasksRef "Study Haskell"

  putStrLn "\n=== Step 2: List Tasks ==="
  listTasks tasksRef

  putStrLn "\n=== Step 3: Remove Task ==="
  removeTask tasksRef 2

  putStrLn "\n=== Step 4: List Tasks After Removal ==="
  listTasks tasksRef

  putStrLn "\n=== Done! ==="

実行結果

=== Step 1: Add Tasks ===
Task added:Task {taskID = 1, title = "Buy broccoli"}
Task added:Task {taskID = 2, title = "Walk the dog"}
Task added:Task {taskID = 3, title = "Study Haskell"}

=== Step 2: List Tasks ===
Task List:
1. Buy broccoli
2. Walk the dog
3. Study Haskell

=== Step 3: Remove Task ===
Task removed.2

=== Step 4: List Tasks After Removal ===
Task List:
1. Buy broccoli
3. Study Haskell

=== Done! ===

サンプルプログラム(JSONパーサー)

TypeScript

ソースコード
type JSONValue = 
  | string
  | number
  | JSONObject;

interface JSONObject {
  [key: string]: JSONValue;
}

function parseJSON(input: string): JSONValue {
  const trimmedInput = input.trim();
  const [result, rest] = parseValue(trimmedInput);
  if (rest.trim() === "") {
    return result;
  } else {
    throw new Error("Parse error");
  }
}

function parseValue(input: string): [JSONValue, string] {
  const firstChar = input[0];

  if (firstChar === '"') {
    return parseString(input);
  } else if (firstChar === '{') {
    return parseObject(input);
  } else if (isDigit(firstChar)) {
    return parseNumber(input);
  } else {
    throw new Error("Invalid JSON value");
  }
}

function parseString(input: string): [string, string] {
  const endQuoteIndex = input.indexOf('"', 1);
  if (endQuoteIndex === -1) {
    throw new Error("Expected closing quote for string");
  }
  const str = input.slice(1, endQuoteIndex);
  const rest = input.slice(endQuoteIndex + 1);
  return [str, rest];
}

function parseObject(input: string): [JSONObject, string] {
  let rest = input.slice(1).trim(); // Skip the opening '{'
  const obj: JSONObject = {};

  while (rest[0] !== '}') {
    const [key, restAfterKey] = parseString(rest);
    rest = restAfterKey.trim();

    if (rest[0] !== ':') {
      throw new Error("Expected ':' after key in object");
    }
    rest = rest.slice(1).trim(); // Skip the ':'

    const [value, restAfterValue] = parseValue(rest);
    obj[key] = value;
    rest = restAfterValue.trim();

    if (rest[0] === ',') {
      rest = rest.slice(1).trim(); // Skip the ','
    } else if (rest[0] !== '}') {
      throw new Error("Expected ',' or '}' in object");
    }
  }

  return [obj, rest.slice(1)]; // Skip the closing '}'
}

function parseNumber(input: string): [number, string] {
  const match = input.match(/^\d+/);
  if (!match) {
    throw new Error("Expected a number");
  }
  const numStr = match[0];
  const rest = input.slice(numStr.length);
  return [parseInt(numStr, 10), rest];
}

function isDigit(char: string): boolean {
  return char >= '0' && char <= '9';
}

// Example usage
const jsonText = '{"name":"Alice","age":20}';
const parsed = parseJSON(jsonText);
console.log(parsed);

Haskell

{-
  Haskell標準ライブラリのData.Charモジュールを読み込んでいる。
    isSpace: 与えられた文字が空白文字(スペース、タブ、改行など)かどうかを判定する。
    isDigit: 与えられた文字が数字(0〜9)かどうかを判定する。
-}
import Data.Char (isSpace, isDigit)

{---------------------------------------
  扱うJSONのデータ構造
    - JString: JSONの文字列を表すデータ型(JString :: String -> JSON)
    - JNumber: JSONの数値を表すデータ型(JNumber :: Int -> JSON)
    - JObject: JSONのオブジェクト(`{...}`)を表すデータ型(JObject :: [(String, JSON)] -> JSON)
---------------------------------------}
data JSON
  = JString String
  | JNumber Int
  | JObject [(String, JSON)]
  deriving Show

{---------------------------------------
  Stringを受け取ってJSONを返す関数
    dropWhile
      条件を満たすまで先頭から要素を捨てていく関数(e.g. dropWhile (< 3) [1,2,3,4,5]) => [3,4,5])
      ここでは、isSpace関数を使って空白文字を捨てている

    case parseValue of
      パターンマッチを行っている
      parseValueの結果が、(result, "")の形になっている場合、resultを返す。反り外はエラーを返す
---------------------------------------}
parseJSON :: String -> JSON
--
parseJSON input = case parseValue (dropWhile isSpace input) of
  (result, "") -> result
  _ -> error "Parse error"

{---------------------------------------
  Stringを受け取って(JSON, String)というタプルを返す関数
    パターンマッチで、入力文字列の先頭文字にもとづいて分岐する
---------------------------------------}
parseValue :: String -> (JSON, String)
{-
  先頭がダブルクォートの場合
-}
parseValue ('"':cs) =
  -- span関数を使って、ダブルクォートが現れるまでの文字列を分離する
  --  strには、ダブルクォート内の文字列が入る
  --  restには、ダブルクォート以降の文字列が入る
  let (str, rest) = span (/= '"') cs
  -- 抽出したstrをJString型にする
  -- drop 1 restで、restの先頭のダブルクォートを削除する
  in (JString str, drop 1 rest)

{-
  先頭が`{`の場合
-}
parseValue ('{':cs) =
  -- parsePairs関数を使って、オブジェクトのキーと値のペアを抽出する
  --  pairsには、キーと値のペアが入る
  --  restには、オブジェクトの終わりの`}`以降の文字列が入る
  let (pairs, rest) = parsePairs (dropWhile isSpace cs)
  -- 抽出したpairsをJObject型にする
  -- drop 1 restで、restの先頭の`}`を削除する
  in (JObject pairs, drop 1 rest)

{-
  先頭が数字の場合
-}
parseValue cs@(c:_)
  -- 入力文字列の先頭がisDigit(数字)の場合、処理が実行されるガード条件
  | isDigit c =
    -- span関数を使って、数字が続く部分を抽出する
    --  numStrには、数字の文字列が入る
    --  restには、数字以降の文字列が入る
      let (numStr, rest) = span isDigit cs
    -- 抽出したnumStrをJNumber型にする
      in (JNumber (read numStr), rest)

{-
  上記parseValueでマッチしない場合
-}
parseValue _ = error "Invalid JSON value"


{---------------------------------------
  オブジェクトのキーと値のペアを抽出する関数
---------------------------------------}
parsePairs :: String -> ([(String, JSON)], String)
{-
  先頭が`}`の場合、([], cs)というタプルを返す
-}
parsePairs ('}': cs) = ([], cs)

{-
  JSONオブジェクトKeyとValueのペアを解析する
-}
parsePairs cs =
  -- 1. parseString関数を使ってキーを抽出する。keyにはキー、rest1には残りも文字列が格納される
  -- 2. dropWhile isSpace (drop 1 rest1)で、先頭の空白文字を削除する
  -- 3. parseValue関数を使ってキーに対応する値を抽出する。valueには値、rest3には残りの文字列が格納される
  -- 4. dropWhile isSpace rest3で、先頭の空白文字を削除する
  -- 5. rest4が`,`の場合、parsePairs関数を再帰的に呼び出す
  -- 6. rest4が`}`の場合、[(key, value)]というタプルを返す
  -- 7. それ以外の場合、エラーを返す
  let (key, rest1) = parseString cs
      rest2 = dropWhile isSpace (drop 1 rest1)
      (value, rest3) = parseValue (dropWhile isSpace rest2)
      rest4 = dropWhile isSpace rest3
    in case rest4 of
      (',': rest5) ->
        let (pairs, rest6) = parsePairs (dropWhile isSpace rest5)
        in ((key, value): pairs, rest6)
      ('}': rest5) -> ([(key, value)], rest5)
      _ -> error "Malformed object"

{---------------------------------------
  ダブルクォートで囲まれた文字列を抽出する関数
---------------------------------------}
parseString :: String -> (String, String)
{-
  先頭がダブルクォートの場合
-}
parseString ('"': cs)
  -- span関数を使って、ダブルクォートが現れるまでの文字列を抽出する
  = let (str, rest) = span (/= '"') cs
    in (str, drop 1 rest)
{-
  上記parseStringでマッチしない場合
-}
parseString _ = error "Expected string"

main :: IO ()
main = do
  let jsonText = "{\"name\":\"Alice\",\"age\":20}"
  let parsed = parseJSON jsonText
  print parsed

実行結果

JObject [("name",JString "Alice"),("age",JNumber 20)]