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

    フロントエンドエンジニアのための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)]