一休.com Developers Blog

一休のエンジニア、デザイナー、ディレクターが情報を発信していきます

TypeScript の Discriminated Union と Haskell の代数的データ型

この記事は 一休.com Advent Calendar 2024 の15日目の記事です。
予定より早く書き上げてしまったので、フライングですが公開してしまいます。

TypeScript の Discriminated Union (判別可能な Union 型) を使うと、いわゆる「代数的データ型」のユースケースを模倣することができます。一休のような予約システム開発においては「ありえない状態を表現しない」方針で型を宣言するためによく利用されています。

「あり得ない状態を表現しない」という型宣言の方針については以下の URL が参考になります。

Designing with types: Making illegal states unrepresentable | F# for fun and profit

このユースケースで Discriminated Union を使う場合、それは文字どおり「型の判別」のために使われます。この場合、判別の手がかりとなる「ディスクリミネーター」はただの分岐のためのシンボル程度の役割にしか見えないでしょう。しかしこれは、本機能の部分的な見方でしかないと考えています。

Haskell など、TypeScript のように模倣ではなく、型システムに代数的データ型がネイティブに組み込まれているプログラミング言語では、代数的データ型こそが新たなデータ型とデータ構造を宣言する手段です。代数的データ構造とパターンマッチを用いて、一般的なオブジェクトだけでなく、リストや木構造などのデータ型を構築・操作することができます。こちらのメンタルモデルから見ると、代数的データ型こそが、データの構築と分解を型安全かつ表現力豊かに扱う基盤を提供するものであり、型駆動開発を支える根幹であると捉えることができます。

本記事では TypeScript の Discriminated Union による代数的データ型の模倣についてまずその基本を確認し、その後 Haskell の代数的データ型の文法をみていきます。後者をみて先のメンタルモデルを獲得したのちに前者を改めて眺めてみることにより、新たな視点で TypeScript の機能を捉えることを目指します。

TypeScript の Discriminated Union (判別可能な Union 型)

TypeScript の Discriminated Union (判別可能な Union 型) を使うと、他のプログラミング言語でいうところの代数的データ型のユースケースを模倣することができます。Discriminated Union はディスクリミネーター (もしくはタグ) と呼ばれる文字列リテラルにより Union で合併した型に含まれる型を判別できるところから「タグつき Union 型」と呼ばれることもあります。

typescriptbook.jp

Discriminated Union をうまく使うと、アプリケーション開発において「存在しない状態」ができることを回避することが出来ます。存在する状態のみを型で宣言することで「存在しない状態ができていないこと」を型チェックにより保証することができます。書籍 Domain Modeling Made Functional などでも語られている非常に有用な実装パターンであり、一休が扱う予約などの業務システム開発でも頻繁に利用しています。

少しその様子を見てみます。

典型例として、何かしらのシステムのユーザー (User) について考えます。ユーザーには会員登録済みの会員 (Member) と、会員登録はしていないゲスト会員 (Guest) の区分があるというのは、よくあるケースでしょう。会員はユーザーID、名前、メールアドレスなどの値をもつが、ゲストはそれらが確定していない。

このとき ユーザーID が null なデータをゲストユーザーとして扱うという実装もあり得ますが、null チェックが必要になるし「ID が null なのがゲスト」という暗黙の仕様を持ち込むことになってしまいます。null に意味は与えたくありません。

そこで以下のように、Member と Guest を定義します。

type User = Member | Guest

type Member = {
  kind: "Member"
  id: number
  name: string
  email: string
}

type Guest = {
  kind: "Guest"
}

User 型のオブジェクトがあったとき、そのオブジェクトが Member 型なのか Guest 型なのかは kind プロパティの値によって判別できます。この kindプロパティが型の判別に使われるディスクリミネーター (あるいはタグ) です。

例えば、Member か Guest かでプレゼンテーションを分けたいというときは以下のように switch 文により Union 型を分解し、それぞれの型ごとに処理を記述することができます。

function showUser(user: User): string {
  switch (user.kind) {
    case "Member":
      return `ID: ${user.id}, Name: ${user.name}, Email: ${user.email}`
    case "Guest":
      return "Guest"
    default:
      assertNever(user)
  }
}

export function assertNever(_: never): never {
  throw new Error("Unexpected value. Should have been never.")
}

assertNever は網羅性チェックのためのイディオムで、これを置くことでナローイングの結果 User 型に含まれるすべての型に対し処理を定義したかを、コンパイル時にチェックすることができます。

以下の絵は実装途中の VSCode です。Member に対する処理は記述したが Guest に対する処理はまだ記述していない段階。コンパイラがエラーを出してくれています。

網羅性チェックによるコンパイルエラー

そして kind プロパティすなわちディスクリミネーターはリテラル型になっており、補完が効きます。

ディスクリミネーターの補完が効く

このように、Union により構造の異なる複数の型を合併しつつもディスクリミネーターによってそれを分解することができ、ナローイングによって型や網羅性チェックが効くことから、代数的データ型をエミューレトできていると言われます。ディスクリミネーターに基づいた switch 文での型の分解は、さながら「パターンマッチ」のように捉えられます。

仮に Discriminated Union を使わず、ゲストユーザーを「ID が null」で表現したとすると以下のように定義することになります。

type User = {
  id: number | null
  name?: string
  email?: string
}

この場合、たとえば ID が null にも関わらず name や email が null でない、という「ありえない状態」を表現できてしまいます。

これは Record 型が AND (積) に基づいたデータ構造の宣言であり、3 つのプロパティがそれぞれ「ある・なし」の 2パターンを取り、その積で合計 8 パターンの状態を取れてしまうことに起因しています。8パターンの状態の中には、実際にはあり得ない状態が含まれます。「ある・ なし」の分岐は ID に関してだけでよいのに、ほかの 2 つのプロパティまでそれに巻き込まれてしまった結果です。

Union 型は OR (和) に基づく合併なので「ID、名前、メールアドレスがある」 Member に、「プロパティがない」 Guest の状態を「足している」だけ。状態の積は取りません。よって合併しても状態が必要以上に増えません。

Making illegal states unrepresentable (ありえない状態を表現しない) というのはこういうことです。

実際のユースケース ··· 絵文字アイコンあるなしの表現

もうひとつ、我々の実際のアプリケーションでの実例の中から、簡単なものを紹介します。

我々の作ってる飲食店向け予約台帳システムには顧客管理の機能がありますが、顧客にタグ付けして分類することができます。タグは視認性向上のため絵文字が設定できるようになっています。

タグには絵文字が使える

タグを新しく作るときは絵文字を設定することができます。絵文字は設定しても、しなくても OK という仕様になっています。

絵文字は設定しても、しなくても OK

さて、このタグ用のアイコンである TagIcon のデータをどう管理するか、型を考えます。

「アイコンがない」というのを null で表現しようとしがちですが、「アイコンなし」という状態はそれはそれで存在する状態と考えることもできます。これを NoIcon という型にしてみます。「ない」を「ある」とみなすことで、状態を定義することができました。

結果、以下のように Union で表現することができるでしょう。こうして null に意味を持たせることを回避します。

type TagIcon = EmojiIcon | NoIcon

type EmojiIcon = {
  kind: "Emoji"
  symbol: string
}

type NoIcon = {
  kind: "NoIcon"
}

型を宣言したからには、この型の値を生成できるようにしましょう。コンストラクタ関数を定義します。このとき、型名と関数名を同じにする コンパニオンオブジェクトパターン を使うと良いです。

function EmojiIcon(symbol: string): EmojiIcon {
  return { kind: "Emoji", symbol }
}

function NoIcon(): NoIcon {
  return { kind: "NoIcon" }
}

少し話しが脱線しますが、EmojiIcon の symbol の文字列が確かに絵文字かどうかをチェックすることで、値の完全性をより厳密にすることができます。

function EmojiIcon(symbol: string): Result<EmojiIcon, ValidationError> {
  return symbol.match(/\p{Emoji}/gu) ? ok({ kind: "Emoji", symbol }) : err(new ValidationError('Emoji ではありません'))
}

プロダクトの実装ではそうしていますが、例外をどう扱うかなど本稿とは関係のないトピックが出てきてしまうので以降省略します。

もとい、これで型、つまりは値の構造の定義とその生成方法を定義できました。あとは先にみた User の例のように、アイコンが絵文字か・絵文字なしかで処理を切り分けたいときは kind プロパティでパターンマッチ的に分解すればよいです。

function toHTMLIcon(icon: TagIcon): string {
  switch (icon.kind) {
    case "Emoji":
      return icon.symbol
    case "NoIcon":
      return ""
    default:
      assertNever(icon)
  }
}

export function assertNever(_: never): never {
  throw new Error("Unexpected value. Should have been never.")
}

追加の仕様で絵文字だけでなく、オリジナルのアップロード画像も扱いたいとしましょう。その場合は Union に新たに ImageIcon 型を追加すればよいでしょう。

type TagIcon = EmojiIcon | NoIcon | ImageIcon // ImageIcon を新たに併合

type EmojiIcon = {
  kind: "Emoji"
  symbol: string
}

type NoIcon = {
  kind: "NoIcon"
}

// これを追加
type ImageIcon = {
  kind: "Image"
  url: string
  name: string
}

ImageIcon 型を Union に追加すると、パターンマッチしている分岐で網羅性チェックが働き、期待通り、コンパイルが通らなくなります。型に応じた処理を追加します。

function toHTMLIcon(icon: TagIcon): string {
  switch (icon.kind) {
    case "Emoji":
      return icon.symbol
    case "NoIcon":
      return ""
    case "Image": // これを追加しないとコンパイルエラー
      return `<img src="${icon.url}" alt="${icon.name}" />`
    default:
      assertNever(icon)
  }
}

実際に作った型を値として使う場合は、以下のような使い方になります。

const icon1 = EmojiIcon("🍣")
const icon2 = NoIcon()
const icon3 = ImageIcon("https://example.com/image.png", "Example Image")

console.log(toHTMLIcon(icon1)) // 🍣
console.log(toHTMLIcon(icon2)) //
console.log(toHTMLIcon(icon3)) // <img src="https://example.com/image.png" alt="Example Image" />

Discriminated Union により型を構造化し、コンパニオンオブジェクトパターンで生成を実装し、switch 文によるナローイングでパターンマッチ的に分解を実装しました。null を使わず NoIcon という状態を導入したおかげで見通しよく、静的検査を有向に活用しながら実装できました。

ディスクリミネーターは、ただの判別用のシンボル?

ここまででも十分、Discriminated Union の有用性が確認できますが、仕組みとしてはオブジェクトのプロパティに kind など適当なプロパティ名でディスクリミネーターを忍ばせた程度にも見えます。

TypeScript レイヤではナローイングによって型チェックが効くなど上手いこと機能していて座布団一枚! という感じ (?) もありますが、JavaScript のレイヤーでみるとただオブジェクトのプロパティの文字列で分岐しているだけのようにも思えて、そんなに本質的な事柄なのか? とも思えてしまいます。

Discriminated Union が表現できるものは、この程度のものと思っておけばいいのでしょうか? いいえ、という話を続けてみていこうと思います。

Haskell のデータ型宣言

代数的データ型を「模倣できる」 TypeScript ではなく、代数的データ型を型システムにネイティブで搭載しているプログラミング言語、たとえば Haskell で同じ実装がどうなるのか、見てみましょう。

以下のように実装できます。

import Text.Printf (printf)

data TagIcon = NoIcon | EmojiIcon String | ImageIcon String String

toHTMLIcon :: TagIcon -> String
toHTMLIcon NoIcon = ""
toHTMLIcon (EmojiIcon symbol) =  symbol
toHTMLIcon (ImageIcon url name) = printf "<img src=\"%s\" alt=\"%s\" >" url name

main :: IO ()
main = do
  let icon1 = NoIcon
      icon2 = EmojiIcon "🍣"
      icon3 = ImageIcon "https://exmaple.com/image.png" "Example Image"

  putStrLn $ toHTMLIcon icon1
  putStrLn $ toHTMLIcon icon2
  putStrLn $ toHTMLIcon icon3

TypeScript での実装に比較すると分量がかなり短くなっています。とは言え、コードが短いかどうかはあまり重要ではありません。より詳細に見てみましょう。

まず、TypeScript のケースとは異なりコンストラクタの明示的な実装がないことに気がつきます。

そして toHTMLIcon 関数の引数でパターンマッチをしていますが、TypeScript のディスクリミネーターに相当するのは文字列リテラル的な値ではなく NoIcon EmojiIcon ImageIcon などのシンボルです。Haskell ではこれを「データコンストラクタ」と呼びます。データコンストラクタにより TagIcon 型の値を分解することができています。

TagIcon 型の宣言にもデータコンストラクタが使われています。データコンストラクタはデータ型の形状や構造を定義するものとしても使われます。

data TagIcon = NoIcon | EmojiIcon String | ImageIcon String String

そして値を生成するときも、データコンストラクタが使われています。

  let icon1 = NoIcon
      icon2 = EmojiIcon "🍣"
      icon3 = ImageIcon "https://exmaple.com/image.png" "Example Image"

このように Haskell ではデータコンストラクタが「タグ付き Union」におけるタグ相当ですが、データコンストラクタは型に基づいた値の分解、データ型の構築、値の生成と、データ型にまつわる操作を提供するものになっています。

TypeScipt で Discriminated Union とコンパニオンオブジェクトパターン、switch 文 と複数の文法を組み合わせて模倣していた機能が、Haskell ではデータコンストラクタという仕組みによって、より密結合された、統一的なかたちで実現されています。これが Haskell における代数的データ型(Algebraic Data Types, ADT)の特徴です。

そして Haskell では新しい型とデータ構造を定義する基本的な方法が、この data キーワードによる宣言です。 ···ということは、このデータコンストラクタを中心とした代数的データ型の文法でより複雑なデータ構造とその型を宣言することができることを意味します。

代数的データ型でより構造的なデータ型を扱う

永続データプログラミングと永続データ構造 - 一休.com Developers Blog で紹介した、二分木 (による永続データ配列) の実装を見てみましょう。実装詳細には立ち入らず、雰囲気だけみてもらえばよいです。

-- データ型の宣言
data Tree a = Leaf a | Node (Tree a) (Tree a)

-- 木を根から走査。パターンマッチと再帰で辿っていく
read :: Int -> Tree a -> a
read _ (Leaf x) = x
read i (Node left right)
  | i < size left = read i left
  | otherwise = read (i - size left) right

write :: Int -> a -> Tree a -> Tree a
write _ v (Leaf _) = Leaf v
write i v (Node left right)
  | i < size left = Node (write i v left) right
  | otherwise = Node left (write (i - size left) v right)

size :: Tree a -> Int
size (Leaf _) = 1
size (Node left right) = size left + size right

fromList :: [a] -> Tree a
fromList [] = error "Cannot build tree from empty list"
fromList [x] = Leaf x
fromList xs =
  let mid = length xs `div` 2
   in Node (fromList (take mid xs)) (fromList (drop mid xs))

main :: IO ()
main = do
  let arr = fromList [1 .. 8 :: Int]

  print $ read 3 arr -- 3

  let arr' = write 3 42 arr

  print $ read 3 arr' -- 42
  print $ read 3 arr  -- 3

重要なポイントとしては、コメントに書いたとおり (1) 完全二分木の木構造を data キーワードのみで宣言していること、(2) 木の中から目的のノードを探すにあたりパターンマッチで分解しながら走査していること、の 2 点が挙げられます。

データ型の宣言を改めてみてみましょう。

data Tree a = Leaf a | Node (Tree a) (Tree a)

Tree 型が再帰的に宣言されているのがわかります。再帰データ型が宣言できるため、木のようなデータ構造を代数的データ型により構築することができます。

さて、こうして木を実装する例をみると代数的データ型は、冒頭でみたような、ただの型を合併して判別する機能というものではなく、まさに「データの型と構造を構築するためのもの」だというのがわかります。

同様にリスト構造の List 型を自前で実装してみましょう。リストの走査として先頭に要素を追加する cons 関数と、リストの値それぞれを写像する mapList 関数も実装してみます。

data List a = Empty | Cons a (List a) deriving (Show)

empty :: List a
empty = Empty

cons :: a -> List a -> List a
cons = Cons

mapList :: (a -> b) -> List a -> List b
mapList _ Empty = Empty
mapList f (Cons x xs) = Cons (f x) (mapList f xs)

-- テスト出力
main :: IO ()
main = do
  let xs = cons 1 (cons 2 (cons 3 empty))

  print (mapList (* 2) xs) -- Cons 2 (Cons 4 (Cons 6 Empty))

先の二分木に同じく、data キーワードにより再帰データ型を定義してリストのデータ構造を構築しています。mapList 関数ではパターンマッチを用いてリストを走査し、リストが保持する値に写像関数を適用しています。データコンストラクタが、データ構造の構築とパターンマッチによる分解双方に利用されていることがわかります。

このように Haskell のデータ型は「値がどのように構造化され、意味づけられるか」を定義する手段です。データコンストラクタはその手段を提供し、構築と分解という双方向の操作を統一的に扱えるようにします。

この観点に立つと、データ型とデータコンストラクタの役割は次のように整理できそうです。

  1. データ型は、プログラム内の「概念モデル」を定義する
  2. データコンストラクタは、そのモデルの構築ルールを提供する
  3. パターンマッチによる分解は、そのモデルを解析し操作する方法を提供する

TypeScript に同様のメンタルモデルを持ち込む

Haskell のデータ型の宣言をここまで見てから、改めて TypeScript に戻ってきましょう。代数的データ型に対するメンタルモデルが大きく更新されているはずです。

その視点で、改めて Discriminated Union よる代数的データ型の模倣を見てみましょう。「 kind プロパティは分岐目的のもの」ではなく Haskell 同様 「データ型を構築、分解する手段」として捉えることができるのではないでしょうか?

さて、TypeScript の型システムも Haskell 同様、再帰データ型は宣言できます。先の Haskell で実装したリストを、TypeScript で、これまでみた Discriminated Union、コンパニオンオブジェクトパターン、switch 文によるパターンマッチのイディオムで、実装してみます。

type List<T> = Empty | Cons<T>

interface Empty {
  kind: "Empty"
}

interface Cons<T> {
  kind: "Cons"
  head: T
  tail: List<T>
}

function Empty(): Empty {
  return { kind: "Empty" }
}

function Cons<T>(head: T, tail: List<T>): Cons<T> {
  return { kind: "Cons", head, tail }
}

type map = <T, U>(f: (a: T) => U, xs: List<T>) => List<U>

const map: map = (f, xs) => {
  switch (xs.kind) {
    case "Empty":
      return Empty()
    case "Cons":
      return Cons(f(xs.head), map(f, xs.tail))
    default:
      assertNever(xs)
  }
}

export function assertNever(_: never): never {
  throw new Error()
}

const xs: List<number> = Cons(1, Cons(2, Cons(3, Empty())))
console.log(map(i => i * 2, xs))

以下が実行結果です。Discriminated Union で構造化されたリストと、各値が写像により倍化された結果が得られています。

$ deno run -A list.ts
{
  kind: "Cons",
  head: 2,
  tail: {
    kind: "Cons",
    head: 4,
    tail: { kind: "Cons", head: 6, tail: { kind: "Empty" } }
  }
}

TypeScript でも無理なく、再帰データ構造を実装できました。

比較してみると TypeScript による代数的データ型は模倣だけあって、Haskell ほど簡潔に表現することはできません。一方で、それをどのようなメンタルモデルで捉えるかは、プログラミング言語の文法には左右されないでしょうから、Haskell のそれ同様に捉えてもよいでしょう。簡潔性は及ばないものの、機能的にはさほど遜色のない実装をすることができました。もちろん、より複雑なパターンマッチを要するものまで実現できるかどうかや、ランタイム性能の影響まで考慮すると Haskell 同等とまではいきませんが。

目論見どおり、TypeScript の Discriminated Union に対する印象をアップデートすることができたでしょうか? できていることを願います 😀

実務で Discriminated Union を用いて再帰データ構造を宣言する、という機会はあまりないとは思いますが、それがただの Union で併合された型を判別できるものと小さく捉えるのではなく、本稿でみた通りデータ型の構築と分解の観点で捉えておくと視点が拡がるでしょうし、より広範囲に適用していってよいものだという確証が得られるのではないかと思います。

余談

TypeScript と Haskell を比較する記事を、過去に幾つか書きました。

TypeScript の型システムは JavaScript の上に後付けされたものということもあり、非常にプラクティカルで便利である一方、個人的には、やや散らかっていてその全体像や各機能の本質を掴みにくいと感じています。Haskell など表現に妥協の少ないプログラミング言語と比較し、相対化することでより深い理解に繋がることは多いです。

Enjoy !

Design Doc でチームを跨いだ開発を円滑に行う

この記事は 一休.com Advent Calendar 2024 7 日目の記事です。

宿泊事業本部 ユーザー向け開発チームの原です。 一休.com と Yahoo!トラベルの主にフロントエンドの開発を担当しています。

今回は、普段の開発でコードを書き始める前段階で Design Doc を作ることで、円滑な開発を進められるようになったというお話をします。

チーム構成について

まず、前提を共有するために私達が普段どのような体制で開発しているかを説明します。
私が所属している宿泊事業本部 ユーザー向け開発チームは、一休.com と Yahoo!トラベルの主に toC のユーザー向けの機能開発をしています。ユーザー向け開発チームのメインのミッションはユーザー体験を向上させることであり、そういった施策の機能開発を素早くリリースできることを大事にしています。
一方、プロダクト開発においては機能開発だけではなく、プログラミング言語や依存ライブラリのアップデートや、アーキテクチャの見直しといったシステムの健全性を向上させる取り組みも重要です。
機能開発とシステム改善を同じチームが両立して行えることが理想的かもしれません。しかし、Nuxt でできたフロントエンドのアプリケーションに関しては、施策に関する機能開発はユーザー向け開発チームが、システム改善はフロントエンド改善チームという専任のチームが担当しています。
これは、変化の激しいフロントエンド開発でベストプラクティスを追い求めるには施策開発とシステム改善をする責務を分けたほうが進めやすいという判断によるものです。
実際、フロントエンド改善チームの取り組みにより、

  • Nuxt2 から 3 へのアップデート
  • Options API から Composition API への書き換え

といった Vue/Nuxt 界隈の進化に追従したり、

  • GraphQL の client-preset の導入
  • デザインシステムの推進

なども機能開発を止めずに完了しています。こういった取り組みにより、かなり開発者体験がいい環境で日々機能開発ができています。

少し古いエントリーですが、フロントエンド改善チームの取り組みは以下でご確認できます。 user-first.ikyu.co.jp

開発チームと改善チームが分かれている状態においては、うまくコミュニケーションを取らないと問題が生じます。
お互いどんな取り組みをするのか共有しないと、

  • 開発チームの施策で触るコードと、改善チームのリファクタリングしたいコードがコンフリクトする
  • 改善チームが行ったリアーキテクトを開発チームがちゃんと理解しないとベストプラクティスではない実装をしてしまう

といったことが起こり得ます。
特に「ベストプラクティスではない実装をしてしまう」というのは避けたい問題です。
そのため、開発チームが実装した機能は小さな修正を除いては基本的に Pull Request (以下 PR) でレビューしてもらうことになっています。
実際レビューの際に、最適な実装にたどり着くまで時間がかかってしまったということが何度かありました。

前置きが長くなりましたが、こうした別のチームにコードレビューを依頼するとき、円滑な開発を進めるために私が必要だと思っていることを紹介します。

コードレビューについて

私はレビュアーとしてコードをレビューするのは非常に労力のかかる仕事だと思っています。
よく「実装が終わって PR を出したので、もう少しで完了します」みたいなことを言ってしまいがちですが、コードレビューは実装と同等か、場合によってはそれ以上の負担が発生しうる作業だと思っています。
というのも、Approve されるとリリースできるという運用においては、レビュアーの仕事はコード書く人(レビュイー)と同等の責任が発生するためです。
いきなり数百行、数千行規模の差分が発生する修正をレビューするときには

  • その施策や修正の背景
  • 実現するための最適な設計になっているか
  • その diff を取り込むことでどんな影響が起こり得るか

などを考える必要がありますが、それらを一から考えるのは、コードを最初から書くのと同じくらいの負担がかかるものです。
上記のような考慮はコードを書く側(レビュイー)は当然考えたうえで実装しているはずなので、レビュイーからレビュアーにうまく伝えられると負担を軽減できます。
どういった工夫でレビュアーの負担を軽減しようとしているかを紹介します。

いきなりコードを書かない

先程も述べたような差分が数百行、数千行規模の PR をいきなりレビューしてもらうのは、PR の description やコメントをいくら丁寧に書いたとしても、レビュアーの負担は大きいです。
そこで実装に入る前の段階で Design Doc を作成して、大筋の実装内容について合意を取るようにしています。
Design Doc は以下のようなアウトラインで書いています。

## このドキュメントの目的
## やりたいこと
// ここではビジネス的な視点でなぜこの施策をするのかを書きます
## 仕様
// ここでは上記のやりたいことを満たす機能要件を書きます
## 対応内容
// ここではシステム的な視点でどんな対応が必要なのかを書きます

このドキュメントの目的、やりたいことが記載された Design Doc のスクショ

Design Doc の目的は、実装者とレビュアーの間で大まかな実装の合意をとることです。

新規ページ作成を例にすると

  • URL をどう命名するか
  • コンポーネントの階層と、各コンポーネントをどう命名するか
  • サーバー(GraphQL)からデータをどのように取得するか
  • 機能要件を満たすロジックをどう実装するか
    • 既存のロジックで使えるものは何か

などを Design Doc で決定します。
特に命名は先に決めておくと実装、レビューともに楽です。

既存のロジックを使えるというアドバイスがもらえる

(↑ 既存のロジックを使えるというアドバイスがもらえる)

Design Doc で事前に実装方針の合意をとることで、「なぜこのような設計にしたのか」をレビュアーがレビュー時に考える必要がなくなります。 また、レビューする段階で大まかな実装イメージがついているので、レビューの負担が軽減されると考えています。

Pull Request を出す際に気をつけていること

Design Doc との乖離がある場合

Design Doc で実装方針の合意をとれたら、実装をして、完了したらレビューに出します。
当然、実装する中で Design Doc で決めた通りにいかなかったり、もっといい方法が見つかったりすることもあるでしょう。
それを何も共有せずレビューに出してしまうとせっかく実装方針を決めた意義が薄れてしまいます。
Design Doc 時の決定と大きく変わる場合は、レビューを出す前に Design Doc 自体を修正して、もう一度合意を取り直すようにしています。
Pull Request の Description やコメントにその旨を書くだけで伝わるような些細な変更の場合は、レビュー段階でそれを伝えるようにします。

レビュアーの負担を最小限に

当然ですが、レビューを依頼する前に自分で見つけられる粗は見つけておくべきなので、自分がレビュアーのつもりでセルフレビューをします。
施策とは直接は関係ないリファクタリングなど、レビュアーが「これはなぜいま修正が必要なのか?」と疑問を持ちそうな箇所はコメントを残しておきます。

  • 動作確認方法
  • 影響する既存機能が元通り動いていることをどうテストしたのか

といった情報も記載します。
また、実装していてもっと良い書き方があるはずだが思いつかなかったような場合、どんなことを試してうまく行かなったということを残しておくとよいでしょう。

最後に

今回はチーム間を跨いだレビューで私が気をつけていることを紹介しました。
常にペアプロ・モブプロを行っていたり、チームの成熟度が高い場合は Design Doc を作成することの必要性は薄いかもしれません。
ただ、実装タイミングでどんな意思決定がなされたのかという情報は、時間が経った後から見返す際、有用になります。
また、

  • レビューのコストは実装と同じくらいのコストになり得る
  • レビュアーの負担はレビュイーの工夫次第で軽減できる

というのはどこでも共通する話だと思います。

永続データプログラミングと永続データ構造

この記事は 一休.com Advent Calendar 2024 の3日目の記事です。

昨今は我々一休のような予約システム開発においても、関数型プログラミング由来のプラクティスを取り入れる機会が増えています。

例えば、値はイミュータブルである方が扱いやすい、関数は副作用のない純粋関数にする方がテスタビリティなども含め何かと都合がよい、そういう場面では積極的に不変な値を使い、関数が冪等になるよう意識的に実装します。ドメインロジックを純粋関数として記述できると、堅牢で責務分離もしやすく、テストやデバッグもしやすいシステムになっていきます。

ところで「関数型プログラミングとはなんぞや」というのに明確な定義はないそうです。ですが突き詰めていくと、計算をなるべく「文」ではなく「式」で宣言することが一つの目標だということに気がつきます。

文と式の違いは何でしょうか?

for 文、代入文、if 文などの文は、基本的には値を返しません。値を返さないということは、文は直接結果を受け取るものではなく、命令になっていると言えます。文は計算機への命令です。

一方の式は、必ず返値を伴いますから、その主な目的は返値を得る、つまり式を評価して計算の結果を得ることだと考えることができます。

customer.archive()

と、文によって暗黙的に customer オブジェクトの内部状態を変更するのではなく

const archivedCustomer = archiveCustomer(customer)

と、引数で与えられた customer オブジェクトを直接変更することなしに、アーカイブ状態に変更されたコピーとしての archivedCustomer オブジェクトを返値として返す、これが式です。この関数は純粋関数として実装し、customer オブジェクトは不変、つまりイミュータブルなものとして扱うと良いでしょう。

式によるイミュータブルなオブジェクトの更新は TypeScript なら

export const archiveCustomer = (customer: Customer): Customer => ({
  ...customer,
  archived: true
})

と、スプレッド構文を使うことで customer オブジェクトのコピーを作りつつ、変更したいプロパティを新たな値に設定したものを返すように実装します。

このように、引数で与えたオブジェクトは直接変更せず、状態を変更した別のオブジェクトを返すような関数の連なりによって計算を定義していくのが関数型プログラミングです。

このあたりの考え方については、過去の発表スライドがありますので参考にしてください。

実際、我々の一部プロダクトのバックエンドでは TypeScript による関数型スタイルでの開発を実践しています。以下はプロダクトのコードの一例で、Customer オブジェクトに新しいメールアドレスの値を追加するための addEmail 関数です。先の実装に同じく、スプレッド構文を使って元のオブジェクトを破壊せずに、メールアドレスが追加されたオブジェクトを返します。

const addEmail =
  (address: EmailAddress) =>
  (customer: Customer): Customer => {
    const newAddress: CustomerEmail = {
      id: generateCustomerEmailId(),
      address,
    }
    return {
      ...customer,
      emails: [...customer.emails, newAddress],
    }
  }

ドメインオブジェクトの状態遷移はすべて、この式による状態遷移のモデルで実装しています。

永続データプログラミング

さて、本記事のテーマは「永続データ」です。永続データとは何でしょうか?

式を意識的に使い、かつ値をイミュータブルに扱うことを基本としてやっていくと、何気なく書いたプログラムの中に特徴的な様子が現れることになります。

以下、リスト操作のプログラムを見てみましょう。リストの先頭や末尾に値を追加したり、適当な値を削除する TypeScript のプログラムです。リストをイミュータブルに扱うべく、値の追加や削除などデータ構造の変更にはスプレッド構文を使い、非破壊的にそれを行うようにします。

// as1: 元のリスト
const as1 = [1, 2, 3, 4, 5];

// as2: 新しいリスト (先頭に 100 を追加)
const as2 = [100, ...as1];

// as3: 新しいリスト (末尾に 500 を追加)
const as3 = [...as2, 500];

// as4: 新しいリスト (値 3 を削除)
const as4 = as3.filter(x => x !== 3);

console.log("as1:", as1); // [1, 2, 3, 4, 5]
console.log("as2:", as2); // [100, 1, 2, 3, 4, 5]
console.log("as3:", as3); // [100, 1, 2, 3, 4, 5, 500]
console.log("as4:", as4); // [100, 1, 2, 4, 5, 500]

更新をしても元のリストは不変なので、as1 を参照しても更新済みの結果は得られません。リスト操作の返り値を as2 as3 as4 とその都度変数にキャプチャし、そのキャプチャした変数に対して次のリスト操作を行います。こうしてデータ構造は不変でありつつも一連の、連続したリスト操作を表現します。

データ構造を不変にした結果、リストが更新される過程の状態すべてが残りました。リストを何度か更新したにも関わらず、変更前の状態を参照することができています。as1 を参照すれば初期状態を、as2as3 で途中の状態を参照することができます。このように値の変更後もそれ以前の状態が残るさまを「永続データ」と呼びます。そして永続データを用いたプログラミングを「永続データプログラミング」と呼びます。

値をイミュータブルに扱うと必然的にそれは永続データになるので、永続データプログラミングはそれ自体、何か特別なテクニックというわけではありません。一方で、値が永続データであることをはっきりさせたい文脈上では「永続データプログラミング」という言葉でプログラミングスタイルを表現すると、その意図が明確になることも多いでしょう。

以下の山本和彦さんの記事では、関数型プログラミングすなわち「永続データプログラミング」であり、永続データを駆使して問題を解くことこそが関数型プログラミングだ、と述べられています。

筆者の関数プログラミングの定義、すなわちこの特集での定義は、「⁠永続データプログラミング」です。永続データとは、破壊できないデータ、つまり再代入できないデータのことです。そして、永続データを駆使して問題を解くのが永続データプログラミングです。

また関数型言語とは、永続データプログラミングを奨励し支援している言語のことです。関数型言語では、再代入の機能がないか、再代入の使用は限定されています。筆者の定義はかなり厳しいほうだと言えます。

第1章 関数プログラミングは難しくない!―初めて学ぶ人にも、挫折した人にもきちんとわかる | gihyo.jp

命令型プログラミングにおいては変更にあたり値を直接破壊的に変更します。変更前のデータ構造の状態を参照することはできません。リストの破壊的変更は、基本的に (式ではなく) 文によって行われるでしょう。文を主体としたプログラミング··· 命令型プログラミングでは、永続ではないデータ、つまり短命データを基本にしていると言えます。一方、式によってプログラムを構成する関数型プログラミングでは、関数の冪等性を確保すべくイミュータブルに値を扱うことになるので、永続データが基本になります。

イミュータブルな値によるプログラミングをする際、そこにある値は不変であるだけでなく、同時に永続データなのだということを認識できると、プログラミングスタイルに対するよりよいメンタルモデルが構築できると思います。

Haskell と永続データプログラミング

やや唐突ですが、イミュータブルといえば純粋関数型言語の Haskell です。先の TypeScript によるリスト操作のプログラムを、Haskell で実装してみます。

main :: IO ()
main = do
  let as1 = [1, 2, 3, 4, 5]
      as2 = 100 : as1
      as3 = as2 ++ [500]
      as4 = delete 3 as3

  print as1 -- [1,2,3,4,5] 
  print as2 -- [100,1,2,3,4,5]
  print as3 -- [100,1,2,3,4,5,500]
  print as4 -- [100,1,2,4,5,500]

Haskell はリストはもちろん、基本的に値がそもそもがイミュータブルです。リスト操作の API はすべて非破壊的になるよう実装されているので、変更にあたり TypeScript のようにスプレッド構文でデータを明示的にコピーしたりする必要はありません。裏を返せば、変更は永続データ的に表現せざるを得ず、式によってプログラムを構成することが必須となります。結果、Haskell による実装は自然と永続データプログラミングになります。

関数型プログラミングすなわち永続データプログラミングだ、というのは、この必然性から来ています。

永続データの特性を利用した問題解決

永続データプログラミングは不変な値を使うことですから、それを実践することで記事冒頭で挙げたようなプログラムの堅牢性など様々なメリットを享受できるわけですが、「変更前の過去の状態を参照できる」という、値が不変であるというよりは、まさに「永続」データの特性が部分が活きるケースがあります。

わかりやすい題材として、競技プログラミングの問題を例に挙げます。

atcoder.jp

問題文を読むのが面倒な方のために、これがどんな問題か簡単に解説します。入力の指示に従ってリストを更新しつつ、任意のタイミングでそのリストの現在の状態を保存する。また任意のタイミングで復元できるようするという、データ構造の保存と復元を題材にした問題です。

ADD 3
SAVE 1
ADD 4
SAVE 2
LOAD 1
DELETE
DELETE
LOAD 2
SAVE 1
LOAD 3
LOAD 1

こういうクエリが入力として与えられる。

  • 空のリストが最初にある
  • クエリを上から順番に解釈して、ADD 3 のときはリスト末尾に  3 を追加する
  • DELETE なら末尾の値を削除
  • SAVE 1 のときは、今使っているリストを ID 番号  1 の領域に保存、LOAD 1 なら ID 番号  1 の領域からリストを復元する
  • クエリのたび、その時点でのリストの末尾の要素を出力する

という問題になっています。

この問題を永続データなしで解こうとすると、リストを更新しても以前の状況に戻れるような木のデータ構造を自分で構築する必要がありなかなか面倒です。一方、永続データを前提にすると、何の苦労もなく解けてしまいます。

以下は Haskell で実装した例です。やっていることは、クエリの内容に合わせてリストに値を追加・削除、保存と復元のときは辞書 (IntMap) に、その時点のリストを格納しているだけです。問題文の通りにシミュレーションしているだけ、とも言えます。

main :: IO ()
main = do
  q <- readLn @Int
  qs <- map words <$> replicateM q getLine

  let qs' = [if null args then (command, -1) else (command, stringToInt (head args)) | command : args <- qs]

  let res = scanl' f ([], IM.empty) qs'
        where
          f (xs, s) query = case query of
            ("ADD", x) -> (x : xs, s) -- リストに値を追加
            ("DELETE", _) -> (drop1 xs, s) -- リストから値を削除
            ("SAVE", y) -> (xs, IM.insert y xs s) -- 辞書にこの時点のリストを保存
            ("LOAD", z) -> (IM.findWithDefault [] z s, s) -- 辞書から保存したリストを復元
            _ -> error "!?"

  printList [headDef (-1) xs | (xs, _) <- tail res] -- 各クエリのタイミングでのリストの先頭要素を得て、出力

Haskell のリストは永続データですから、値を変更しても変更以前の値が残ります。その値が暗黙的に他で書き換えられる事はありません。よって素直にリストを辞書に保存しておけばよいのです。一方、命令型プログラミングにおいてリストがミュータブルな場合は、ある時点の参照を辞書に保存したとしても、どこかで書き換えが発生すると、辞書に保存された参照の先のデータが書き換わるためうまくいきません。

永続データ構造

さて、ここからが本題です。TypeScript でリストを永続データとして扱うにあたり、スプレッド構文によるコピーを使いました。

// as2: 新しいリスト (先頭に 100 を追加)
const as2 = [100, ...as1];

// as3: 新しいリスト (末尾に 500 を追加)
const as3 = [...as2, 500];

すでにお気づきの方も多いと思いますが、値の更新にあたり、リスト全体のコピーが走ってしまっています。一つ値を追加、削除、更新するだけでもリストの要素  n 件に対し  n 件のコピーが走る。つまり  O(n) の計算量が必要になってしまいます。永続データプログラミングは良いものですが、ナイーブに実装するとデータコピーによる計算量の増大を招きがちです。

Haskell など、イミュータブルが前提のプログラミング言語はこの問題をどうしているのでしょうか?

結論、データ構造全体をコピーするのではなく「変更されるノードとそのノードへ直接的・間接的に参照を持つノードだけをコピーする」ことによって計算量を抑え、不変でありながらも効率的なデータ更新が可能になるようにリストその他のデータ構造が実装されています。つまり同じ「リスト」でも、命令型プログラミングのそれと、不変なデータ構造のそれは実装自体が異なるのです。抽象は同じ「リスト」でも具体が違うと言えるでしょう。

変更あったところだけをコピーし、それ以外は元の値と共有を行うこのデータ構造の実装手法は Structural Sharing と呼ばれることもあります。Structural Sharing により不変でありながら効率的に更新が可能な永続データのデータ構造を「永続データ構造」と呼びます。

永続データ構造については、以下の書籍にその実装方法含め詳しく記載されています。

純粋関数型データ構造 - アスキードワンゴ

もとい、例えば Haskell のリストは先頭の値を操作する場合は  O(1) です。先頭要素だけがコピーされていて、それ以降の要素が更新前後の二つのリストで共有されるからです。

同じく、先の実装でも利用した Data.IntMap という辞書、こちらも永続データ構造ですが、内部的にはパトリシア木で実装されていて、値の挿入やキーの探索は、整数のビット長程度の計算量 ···  n をデータサイズ、 W をビット長としたとき  O(min(n, W)) に収まります。

Haskell で利用する標準的なデータ構造 ··· List、Map、Set、Sequence、Heap は、すべてイミュータブルでありながら、値の探索や変更が  O(1) O(\log n) 程度の計算量で行える永続データ構造になっています。(なお、誤解の無いよう補足すると、ミュータブルなデータ構造もあります。ミュータブルなデータ構造は手続き的プログラミングで変更することになります)

永続データ構造を利用することによって、永続データプログラミング時にもパフォーマンスをそれほど犠牲にせず、大量のデータを扱うことが可能になります。裏を返せば、永続データプログラミングをより広範囲に実践していくには、永続データ構造が必要不可欠であるとも言えます。関数型プログラミングは値が不変であることをよしとしますが、そのためには永続データ構造が必要かつ重要なパーツなのです。

TypeScript その他のプログラミング言語で永続データプログラミングを実践するとき、純粋関数型言語とは異なり、素の状態では永続データ構造の支援がないということは念頭に置いておくべきでしょう。

TypeScript や Python で永続データ構造を利用するには?

TypeScript の Array、Map、Set などの標準的なデータ構造はすべて命令型データ構造、つまりミュータブルです。命令型のプログラミング言語においては、どの言語も同様でしょう。一方、プログラミング言語によっては List、Map、Set などの永続データ構造バージョンを提供するサードパーティライブラリがあります。

これらのライブラリを導入することで、TypeScript や Python で永続データ構造を利用することができます。しかし、実際のところこれらの永続データ構造の実装が、広く普及しているようには思えません。

永続データ構造は業務システム開発にも必須か?

結論からいうと、命令型のプログラミング言語で業務システム開発をする場合には、必須ではないでしょう。

永続データプログラミング自体は良い作法ですが、業務システムにおいては、大量データのナイーブなコピーが走るような実装をする場面が少ないから、というのが理由だと思います。

Haskell のような関数型言語を使っているのであれば、永続データ構造は標準的に提供されていて、そもそも必須かどうかすら気にする必要がありません。永続データ構造のメカニズムを全く知らなくても、自然にそれを使ったプログラムを書くように導かれます。

命令型言語を使いつつも、永続データプログラミングを実践するケースではどうでしょうか? 速度が必要な多くの場面では、いったん永続データを諦め、単に命令型データ構造を利用すれば事足りるので、わざわざ永続データ構造を持ち出す必要はないでしょう。ドメインオブジェクトの変更をイミュータブルに表現するためコピーする場合も、せいぜい 10 か 20 程度のプロパティをコピーする程度で、コピー 1回にあたり数万件といったオーダーのコピーが発生するようなことは希でしょう。

よって業務システム開発において Immutable.js や pyrsistent のようなサードパーティライブラリを積極的に使いたい場面は、先に解いた競技プログラミング問題のように、永続データ構造の永続である特性そのものが機能要件として必要になるケースに限られるのではないか? と思います。

Immutable.js の開発が停滞しているのは、フロントエンドで永続データ構造の需要が乏しいからでしょう。このようなデータ構造自体は非常に重要な概念で、多くのプログラミング言語に存在します。我々フロントエンドエンジニアが依存するブラウザの内部でも、効率的なデータ処理のために多用されているはずです。しかし、フロントエンドエンジニアがイミュータブルに求めているのは処理速度ではなく設計の改善です。だからこそ、Immutable.js に代わって Immer が隆盛したのでしょう。

Immutable.jsとImmer、ちゃんと使い分けていますか?

一方、純粋関数型言語で競技プログラミングのような大きなデータを扱うプログラミングを行う場合、永続データ構造は必須ですし、また永続データ構造を利用していることを意識することでよりよい実装が可能になると思っています。個人的にはこの「永続データ構造によって、より良い実装が可能になる」点こそが本質的だと思っています。

先の競技プログラミングの実装を改めてみてみます。

main :: IO ()
main = do
  q <- readLn @Int
  qs <- map words <$> replicateM q getLine

  let qs' = [if null args then (command, -1) else (command, stringToInt (head args)) | command : args <- qs]

  let res = scanl' f ([], IM.empty) qs'
        where
          f (xs, s) query = case query of
            ("ADD", x) -> (x : xs, s) -- リストに値を追加
            ("DELETE", _) -> (drop1 xs, s) -- リストから値を削除
            ("SAVE", y) -> (xs, IM.insert y xs s) -- 辞書にこの時点のリストを保存
            ("LOAD", z) -> (IM.findWithDefault [] z s, s) -- 辞書から保存したリストを復元
            _ -> error "!?"

  printList [headDef (-1) xs | (xs, _) <- tail res] -- 各クエリのタイミングでのリストの先頭要素を得て、出力 (※)

このプログラムでは、クエリのたびに、その時点でのリストの値を出力する必要があります。が、上記のプログラムでは (クエリのたびに都度出力を得ているのではなく) クエリを全部処理し終えてから、最終的な出力、つまりプレゼンテーションを組み立てています。(※) の実装です。

データ構造が命令型データ構造の場合、こうはいきません。ある時点のデータ構造の状態はその時点にしか参照できないため、プレゼンテーションをそのタイミングで得る必要があります。

一方、永続データ構造の場合、各々時点のデータ構造の状態を後からでも参照できますし、メモリ上にデータ構造を保持しておいても Structural Sharing によりそれが肥大化することもありません。このプログラムのように、中核になる計算 ··· つまりドメインロジックをすべて処理し終えてから、改めてプレゼンテーションに変換することが可能です。プレゼンテーション・ドメイン分離の観点において、永続データ構造が重要な役割を果たしています。この考え方は、実装スタイルに大きな影響を与えます。

この点に関する詳細は、競技プログラミング文脈を絡めて話す必要もあり長くなりそうなので改めて別の記事にしようと思います。

さて、業務システム開発には必須とは言えないと私見は述べましたが、命令型プログラミング言語でも値を不変に扱うとき、このナイーブなコピーが走る問題を意識できているかどうかは重要でしょう。多くの関数型言語においてはこの課題を永続データ構造によって解消しているということは、知っておいて損はありません。

永続データ構造の実装例

「永続データ構造」というと字面から何かすごそうなものを思い浮かべるかもしれませんが、その実装方法を知っておくともう少し身近なものに感じられると思います。永続データ構造の中でも比較的実装が簡単な、永続スタックと永続配列の実装を紹介して終わりにしたいと思います。実装の詳細については解説しませんが、雰囲気だけみてもらって「何か特別なことをしなくても普通に実装できるんだな」という雰囲気を掴んでもらえたらと思います。

永続スタック

Haskell で実装した永続スタックの一例です。再帰データ型でリストのようなデータ構造を宣言し、API として head tail (++) など基本的な関数を実装します。

代数的データ型でリンクリスト構造を宣言し、先頭要素への参照を返すように実装します。先頭要素を参照したいとき (head) は、先頭要素への参照からそれを取り出し値を得るだけ。先頭以外の要素を得る、つまり分解したいとき (tail) は次の要素への参照を返す。これだけで永続スタックが実装できます。

二つのスタックを結合する ((++)) ときはどうしても  O(n) かかってしまいますが、その際も双方のリストをコピーするのではなく古いリストの一方だけをコピーし、のこりの一つは新しいリストで共有されるように実装しています。

import Prelude hiding ((++))

data Stack a = Nil | Cons a (Stack a) deriving (Show, Eq)

empty :: Stack a
empty = Nil

isEmpty :: Stack a -> Bool
isEmpty Nil = True
isEmpty _ = False

cons :: a -> Stack a -> Stack a
cons = Cons

head :: Stack a -> a
head Nil = error "EMPTY"
head (Cons x _) = x

tail :: Stack a -> Stack a
tail Nil = error "EMPTY"
tail (Cons _ xs) = xs

(++) :: Stack a -> Stack a -> Stack a
Nil ++ ys = ys
Cons x xs ++ ys = Cons x (xs ++ ys)

main :: IO ()
main = do
  let s0 :: Stack Int
      s0 = empty
      s1 = cons (1 :: Int) s0
      s2 = cons (2 :: Int) s1
      s3 = cons (3 :: Int) s1

      s4 = s1 ++ s3

  print s0
  print s1
  print s2
  print s3
  print s4

出力結果は以下です。

Nil
Cons_1_Nil
Cons_2_(Cons_1_Nil)
Cons_3_(Cons_1_Nil)
Cons_1_(Cons_3_(Cons_1_Nil))

永続配列

永続配列は、配列といっても命令型の配列のように連続した領域を索引で参照できるようにするモデルではなく、完全二分木で表現します。

値は葉に持たせて、インデックスによる参照時には根から二分木を辿って目的の葉を特定します。そのため、参照時の計算量は  O(1) ではなく  O(\log n) となります。

二分木による配列の表現

更新時には「変更されるノードとそのノードへ直接的・間接的に参照を持つノードだけをコピーする」という考えに従い、根から更新対象の葉までを辿る経路上のノードをコピーする経路コピーという手法を使います。経路をコピーするといっても、木の高さ程度ですから更新も結局  O(\log n) になります。

経路コピーについては Path Copying による永続データ構造 - Speaker Deck のスライドがわかりやすいと思います。

{-# LANGUAGE DeriveFunctor #-}

import Prelude hiding (read)

data Tree a = Leaf a | Node (Tree a) (Tree a) deriving (Show, Functor)

fromList :: [a] -> Tree a
fromList [] = error "Cannot build tree from empty list"
fromList [x] = Leaf x
fromList xs =
  let mid = length xs `div` 2
   in Node (fromList (take mid xs)) (fromList (drop mid xs))

read :: Int -> Tree a -> a
read _ (Leaf x) = x
read i (Node left right)
  | i < size left = read i left
  | otherwise = read (i - size left) right

write :: Int -> a -> Tree a -> Tree a
write _ v (Leaf _) = Leaf v
write i v (Node left right)
  | i < size left = Node (write i v left) right
  | otherwise = Node left (write (i - size left) v right)

size :: Tree a -> Int
size (Leaf _) = 1
size (Node left right) = size left + size right

main :: IO ()
main = do
  let arr = fromList [1 .. 8 :: Int]
  print arr

  print $ read 3 arr

  let arr' = write 3 42 arr

  print $ read 3 arr'
  print $ read 3 arr

永続スタック、永続配列の実装を簡単ですが紹介しました。

何か特殊な技法を使うというものではなくスタック、配列などの抽象が要求する操作を考え、その抽象に適した具体的で効率的なデータ構造を用意する、というのが永続データ構造の実装です。

まとめ

永続データプログラミングと永続データ構造について解説しました。

  • 不変な値を使い、式でプログラムを宣言すると永続データプログラミングになる
  • 永続データプログラミングでは、変更前の値を破壊しない。変更後も変更前の値を参照できるという特徴を持つ
  • 関数型プログラミングすなわち永続データプログラミングである、とも考えられる
  • 永続データプログラミングにおけるデータコピーを最小限に留め効率的な変更を可能にする不変データ構造が「永続データ構造」
  • 業務システム開発において、永続データ構造は必須とは言えない。パフォーマンスが必要な場面で、永続データ構造を持ち出す以外の解決方法がある
  • 大量データを扱うことが基本で、かつ値を不変に扱いたいなら永続データ構造は必須
  • 一般のシステム開発においても機能要件として「永続」データが必要になるなら、Immutable.js とかを利用しても良いかも
  • 関数型プログラミングが、不変でありながらも値の変更をどのように実現しているかは永続データ構造に着目するとよく理解できる

というお話でした。

途中少し触れた、永続データ構造を前提にした計算の分離については別途あらためて記事にしたいと思います。

追記

以下に記事にしました。

zenn.dev

一休.com Developers Blogの執筆環境2024

この記事は一休.com Advent Calendar 2024の1日目の記事です。

kymmtです。

当ブログ「一休.com Developers Blog」は、以前からはてなブログで運用しています。そして、今年からは執筆環境を少し改善しました。具体的には、GitHubを用いて記事の作成や公開ができるようにしました。

この記事では、当ブログの執筆環境をどのように改善し、ふだん運用しているかについて紹介します。

HatenaBlog Workflows Boilerplateの導入

従来は、執筆者が記事をローカルやブログ管理画面のエディタ上で書き、なんらかの方法でレビューを受け、公開するというフローでした。このフローで一番ネックになりやすいのはレビューで、Slack上でレビューが展開されがちになり、議論を追いづらいという問題がありました。

そこで、執筆環境の改善のために、はてなさんがβ版として公開しているHatenaBlog Workflows Boilerplateを利用して、記事執筆用のリポジトリを整備しました。

github.com

これは、GitHub Actionsのはてなブログ用reusable workflow集であるhatenablog-workflowsを用いた、はてなブログ記事執筆用のリポジトリテンプレートです。

リポジトリを整備した結果、GitHub上ではてなブログの記事をMarkdownファイルとして管理したり、GitHub Actionsで原稿の同期や公開などの操作を実行できるようになりました。

現在は、記事執筆のフローは次のようになっています。

  1. 下書き用pull request (PR)作成actionを実行
  2. 手元にブランチを持ってきて執筆
  3. ときどきコミットをpushしてはてなブログに同期し、プレビュー画面で確認
  4. PR上でレビュー
  5. 公開できる状態にしてPRをmainにマージし、自動で記事公開

普段開発に携わっているメンバーはGitHubに慣れています。そのようなメンバーがPR上でブログ記事執筆やレビューができるようにすることでの執筆体験向上を図りました。とくに、レビューをPRで実施することで、あるコメントがどの文章に対するものなのか分かりやすくなる点は便利だと感じています。社内のメンバーからも、

ブログが GitHub で管理できるようになったのは、レビューの観点でホントありがたい

という感想をもらっています。

記事のネタ管理

記事のネタはGitHubのissueとして管理しています。どの執筆PRでネタが記事になったのかや、ネタに関する細かいメモも記録しています。情報の一元化の観点では、スプレッドシートなどで別管理するよりは好ましいと思います。

校正

校正はtextlintを利用しています。pull_requestトリガーでtextlintによる校正を実行するGitHub Actionsのワークフローを設定しています。ワークフローは.github/workflows/textlint.yamlに次のようなものを置いています。

name: textlint

on:
  pull_request:
    paths:
      - "draft_entries/*.md"
      - "entries/*.md"

jobs:
  run:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
      - run: npm ci
      - name: Add textlint problem matcher
        run: echo "::add-matcher::.github/textlint-unix.json"
      - name: Lint files added/modified in the PR
        run: |
          git diff --diff-filter=AM origin/main...HEAD --name-only -- '*.md' | xargs npx textlint --format unix

.github/textlint-unix.jsonは次のとおりです。

{
  "problemMatcher": [
    {
      "owner": "textlint-unix",
      "pattern": [
        {
          "regexp": "^(.+):(\\d+):(\\d+):\\s(.*)$",
          "file": 1,
          "line": 2,
          "column": 3,
          "message": 4
        }
      ]
    }
  ]
}

これでPR画面上にtextlintによる校正結果が表示されるようになります。

problem matcherによるtextlintの校正結果の表示

textlintで現在使っているルールは次のとおり最低限のものです。技術記事もテクニカルライティングの一種であるとは思いますが、Web上で気軽に読んでもらう類のものでもあるため、文体にある程度個性が出るのは問題ないと考え、そこまで厳しいルールにはしていません。

レビュアー

記事ファイルが配置されるディレクトリのコードオーナーに技術広報担当者を設定して、担当者のレビューが通れば記事を公開してOK、というフローにしました。現在は2名の担当者で回しています。

HatenaBlog Workflows Boilerplateを利用しているという前提で、.github/CODEOWNERSを次のように設定しています。ここで@orgにはGitHub orgの名前が、teamには技術広報担当者からなるteamの名前が入ります。

/draft_entries/ @org/team
/entries/ @org/team

この設定に加えて、リポジトリのbranch protection rulesとしてmainブランチでコードオーナーのレビューを必須にすることで、必ず技術広報担当者が目を通せる仕組みとしています。

とはいえ、各記事の内容については執筆者のチームメンバーのほうが深く理解していることも多いです。ですので、内容の正確性という観点ではチームメンバーどうしでレビューしてもらい、技術広報担当者は会社の名前で社外に公開する記事としてふさわしいかという観点でのチェックをすることが多いです。

余談: HatenaBlog Workflows Boilerplateへのフィーチャーリクエスト

上記のBoilerplateを用いてリポジトリを運用しているうちに、ほしい機能が1つ生まれたので、該当リポジトリのissueを通じて機能をリクエストしました。

github.com

具体的には、下書きPRを作成したとき、そのPRはdraft状態になっていてほしいというものです。GitHubの仕様上、PRがdraftからready for reviewになるとき、コードオーナーへレビュー依頼の通知が送られるようになっています。ですので、記事を書き始めるときのPRとしてはdraftとするのが自然と考えた、というものです。

結果、機能として無事取り込んでいただけました。ありがとうございました1

staff.hatenablog.com

おわりに

この記事では2024年の当ブログの執筆環境について紹介しました。GitHubを起点とする執筆環境や自動化を取り入れることで、執筆体験を向上できました。この記事も紹介した仕組みで書きました。引き続き、このブログで一休での開発における興味深いトピックをお届けしていければと思います!


  1. 機能リリース後に、draft機能がGitHubの有料プランの機能であることに伴うフォローアップもしていただきました。ありがとうございました

一休はRust.Tokyo 2024にゴールドスポンサーとして協賛します

kymmtです。

11/30に開催されるRust.Tokyo 2024に一休はゴールドスポンサーとして協賛します。

rust.tokyo

一休でのRustの活用

一休では一休.comレストランにおいてRustの活用を進めています。昨年に当ブログで活用の様子を紹介した際は、当時開発が進んでいたレストラン予約サービスWeb UIのバックエンドにおけるユースケースだけに触れていました。

user-first.ikyu.co.jp

それから1年弱経過した現在では、Rust活用の場はさらに広がっており、Rustを書いているメンバーも増えてきています。

Rust.Tokyoのゴールドスポンサー

2024年はRustでWebサービスを開発するのがますます現実的な選択肢として挙がるようになった年だったのではないかと思います。たとえば、RustでWebアプリケーションを開発するための本が複数発売されるなど1、学習リソースは着実に揃ってきています。このような環境のなかで、一休もRust採用企業として活用事例の共有などを通じてコミュニティを活性化させることが重要だと考えています。

以上のような状況に基づいて、一休はこのたびRust.Tokyo 2024にゴールドスポンサーとして協賛させていただくことになりました。当日は会場で

  • スポンサーセッション登壇
  • ブースの出展

をやります。

スポンサーセッションでは、トラックAで15:25から「総会員数1,500万人のレストランWeb予約サービスにおけるRustの活用」と題してkymmtが発表します。2024年現在の一休.comレストランにおけるRust活用の様子のスナップショットとして、システムの設計や技術的なトピックについて紹介する予定です。

また、会場ではブースを出展する予定です。技術広報チームを中心に、一休.comレストランのエンジニアとしてkymmtが、ほかにも宿泊予約サービスの一休.comのエンジニアとEMがブースに参加する予定です。一休のサービス開発の様子について興味があるかたはぜひお越しください。

おわりに

11/30に一休はRust.Tokyo 2024にゴールドスポンサーとして協賛します。参加者のかたは当日会場でお会いしましょう!

TSKaigi Kansai 2024とJSConf JP 2024に一休のエンジニアが登壇します

kymmtです。

今月は

と、JavaScript/TypeScriptに関するカンファレンスが2つ開催されます。今年は、一休.comレストランのフロントエンドアーキテクトを務めるエンジニア恩田 (@takashi_onda)がこれらのカンファレンス両方に登壇します。

1週違いで開催されるそれぞれのカンファレンスでは、一休.comレストランのフロントエンド開発をきっかけとする内容のトークテーマを携えて登壇します。この記事では、現在絶賛発表準備中の本人からのコメントも交えつつ、発表内容について紹介します!

TSKaigi Kansai 2024: 構造的型付けとserialize境界

kansai.tskaigi.org

TSKaigi Kansai 2024は、TypeScriptをテーマとして2024年5月に東京で開催されたTSKaigi 2024から派生した地域型カンファレンスです。

本カンファレンスでは、16:00-16:30に「カミナシ堂」会場で「構造的型付けとserialize境界」というタイトルで恩田が発表します。

以下、恩田からのコメントです。

京都在住なので、地元でお話しできるのが楽しみです。一休では僕のように関西やその他の地域に在住しているエンジニアも複数います。発表を聞いていただき興味を持たれた方は、お気軽にお声がけください。

JSConf JP 2024: Reactへの依存を最小にするフロントエンドの設計

jsconf.jp

JSConf JP 2024は、Japan Node.js Association様により運営されている、JavaScriptをテーマとする大規模カンファレンスです。

本カンファレンスでは、11:10-11:40にトラックBで「Reactへの依存を最小にするフロントエンドの設計」というタイトルで発表します。この発表は、本ブログで以前公開した記事「React / Remixへの依存を最小にするフロントエンド設計」をもとに、今回あらためてJSConfでお話しするものです。

user-first.ikyu.co.jp

以下、恩田からのコメントです。

一休レストランのフロントエンド開発では、疎結合な設計を心がけていて、ほとんどのロジックは Vanilla JS だけで完結しています。そのような書き方を可能とする、バックエンド開発で培われた知見をフロントエンドに適用する手法についてお話ししたいと思います。

おわりに

この記事では、TSKaigi Kansai 2024とJSConf JP 2024でのエンジニア恩田 (@takashi_onda)の発表内容について紹介しました。

参加者の方は、それぞれの会場でぜひ発表を聞きに来ていただければと思います!

Vue Fes Japan 2024に登壇 & ランチスポンサーをしました

CTO室プラットフォーム開発チームのいがにんこと山口(@igayamaguchi)です。

先日、Vue Fes Japan 2024が開催され、一休は登壇とスポンサーをしました。その紹介をします。

Vue Fes Japan 2024が開催

10月19日(土)に日本最大級の Vue.js カンファレンス、Vue Fes Japan 2024 が開催されました。一休は当カンファレンスでプロポーザル採択による発表と、ランチスポンサーとしてランチセッションでの発表を行いました。この記事ではその発表の概要を紹介します。

一休で行った発表は2つです。

  • Vue.js、Nuxtの機能を使い、 大量のコピペコードをリファクタリングする
  • Nuxtベースの「WXT」でChrome拡張を作成する

Vue.js、Nuxtの機能を使い、 大量のコピペコードをリファクタリングする

1つ目の発表は自分がプロポーザルを送り採択された発表です。一休.com、Yahoo!トラベルで発生したコピペ問題の原因と解決策の話です。

speakerdeck.com

一休.com、Yahoo!トラベルでは大量のコピペコードが発生していました。それにより何かを変更するときに作業がN倍になってしまい、開発スピードが落ちていました。 今回の発表ではこの問題の原因を紐解き解決していった話をしました。 コピペ問題といっても原因は様々です。ワークフローに問題があったり、コピペが発生せざるを得ない技術的背景があったり。それぞれに対応しています。 内容をざっくり抜き出すと、以下のようになります。

  • 開発ワークフローに問題がありUIのコピペが横行していたので、ワークフローの見直し、UIコンポーネントの作成
  • デザイン差分を小さくしてコードの統一
  • Option APIでロジックが共通化できていなかったので、Composition APIを導入して共通化、さらには責務わけのためのコンポーネント分割

詳細は発表資料をご覧ください。

Nuxtベースの「WXT」でChrome拡張を作成する

2つ目は新規プロダクト開発部の星野によるランチセッションでの発表です。「WXT」というOSSを使用し、Chrome拡張を作成してみた話です。

speakerdeck.com

何かを便利にしようとChrome拡張を使おうと思った時、自分の求めるChrome拡張が存在しなかったり、信用できない開発者のChrome拡張を使用したくないということがあります。この問題を解決するために「WXT」というOSSを使用してChrome拡張を開発した話をしました。

WXTはNuxtベースで開発できる拡張機能開発フレームワークです。Chrome拡張を開発するにはいくつか設定ファイルを作成したりChrome拡張特有の作業が必要でとても面倒なのですが、WXTを使用することでこの作業がとても簡単になります。しかもNuxtを使用している人には分かりやすいインターフェースになっており、Vue.js/Nuxtを触っている開発者にはとても開発しやすいものになっています。さらにはVue.jsだけでなくReactなど他のUIフレームワークを使用して開発することもできます。

自分でChrome拡張を作りたいという方はこの発表を参考にWXTを試してみてください。

おわりに

Vue Fes Japan 2024での一休の発表を紹介させていただきました。

Vue Fesですが、発表者、かつ一視聴者として参加してみて、すごく熱があるカンファレンスでした。参加者も多く、Vue.jsだけでなくViteの話も聞けて、さらにRolldownやoxcの話を開発者から聞けるという場は国内ではなかなかないと思います。そのような場で発表できたこと、スポンサーをできたことはとてもありがたかったです。

2025年も開催されるとのことなのでまた楽しみです。


一休では、ともに良いサービスをつくっていく仲間を募集中です。

hrmos.co

カジュアル面談も実施しているので、お気軽にご応募ください。

www.ikyu.co.jp

「一休×AEON 事業会社のサービスを支える基盤開発トーク」を開催しました

一休×AEONイベントの様子

はじめに

kymmtです。

先日2024年9月18日に、「事業会社のサービスを支える基盤開発トーク」と題してイオンスマートテクノロジー(以下AST)さんと合同で技術イベントを実施しました。

ikyu.connpass.com

イベントでは、各会社の事業を支える基盤プロダクトの開発や運用における苦労や工夫について登壇者の方々にお話しいただきました。

この記事では、このイベントの様子や発表の内容を紹介します。なお、X(旧Twitter)でも#ikyu_aeonというハッシュタグで当日の様子がご覧になれます。

会場

会場は、一休のオフィスも入っている東京ガーデンテラス紀尾井町内のLODGEでした。

当日は、一休/ASTのメンバーも合わせて計30人程度の方にご来場いただきました。また、イベント開始前に、ASTのもりはやさんからトップバリュ製品やイオンで販売中のお菓子を来場者向けに提供していただきました。

発表

『歴史あるプロダクトにマイクロサービスを導入するプロセス』

1つ目の発表は、一休の菊地さんによる『歴史あるプロダクトにマイクロサービスを導入するプロセス』でした。

一休のプロダクトを横断して利用されるマイクロサービス群(社内では一休プラットフォームと呼んでいます)を開発するチームでは、個別のプロダクトへのマイクロサービス導入も進めています。長い歴史を持ちコードベースが複雑なプロダクトにマイクロサービスを導入するには、技術課題の解決にとどまらず、積み重なった仕様を解きほぐして関係者と調整を進める必要もあります。

この発表では、そのような課題に直面した一休.comレストランへのポイント管理マイクロサービス導入の事例を紹介いただきました。発表のなかで、デッドコード削除や仕様の調整のような地道な取り組みから、プロダクトとマイクロサービスを疎結合に保ちつつ連携する仕組みを解説していただきました。

『1000万DLを超えたiAEONアプリ:完全停止を防ぐ耐障害性と信頼性の設計』

2つ目の発表は、ASTの范さんによる『1000万DLを超えたiAEONアプリ:完全停止を防ぐ耐障害性と信頼性の設計』でした。

iAEONアプリは実店舗のレジ前で会員バーコードを表示するようなユースケースを想定しており、障害が発生して利用不可になることを可能な限り避けたいという要件があります。一方で、複雑なバックエンド側の一部で発生した障害がアプリに伝播することで、アプリが利用不可になることも過去あったとのことでした。

この発表では、上述したような特性を持つiAEONアプリの耐障害性を高めるための取り組みについてお話しいただきました。とくに、デバイス上のキャッシュなどを活用してフロントエンドであるiAEONアプリ上のエラーをできるだけ局所化する手法について詳しく解説いただきました。App StoreやGoogle Play StoreにおけるiAEONアプリの評価が結果的に向上したとのことで素晴らしいと感じました。

『宿泊予約サイトにおける検索と料金計算の両立』

3つ目の発表は、一休の鍛治さんから『宿泊予約サイトにおける検索と料金計算の両立』でした。

宿泊予約サイトである一休.comやYahoo!トラベルでの料金に関する業務ルールは複雑です。また、ユーザーが宿泊したい部屋をさまざまな条件に基づいて検索するときに、その複雑な業務ルールで算出される料金の検索サーバであるSolrで計算する必要があります。

この発表では、そのような一休.comの検索要件の複雑さの解説や、要件を満たすためにSolrのプラグイン機構を活用していることについて解説いただきました。SolrのプラグインはJavaで開発することができ、Javaの機能を活用できるので、Solrクエリをメンテナブルに保ちつつ、複雑な料金計算を検索時に実行できているとのことでした。

『SRE改善サイクルはチームを超えて - ダッシュボードを眺める会の取り組み』

4つ目の発表は、ASTのもりはやさんから『SRE改善サイクルはチームを超えて - ダッシュボードを眺める会の取り組み』でした。

New Relicなどを活用したシステムメトリクスを概観できるダッシュボードは、非常時に障害の原因を探るためのものとして非常に便利です。一方で、定点観測することで、日々のシステムの状態や、障害になり得る変化を見つけることができます。

この発表では、ASTのSREチームを中心にダッシュボードを定期的に眺める会を設けることで、メトリクスの変化や改善ポイントを発見して深掘りする機会を意図的に作り出す取り組みについてお話しいただきました。システムの信頼性を高める機会を作り出すのはもちろん、会を通じて「ザイオンス効果(単純接触効果)」でチームビルディングにも寄与している点が素晴らしいと感じました。

おわりに

「事業会社のサービスを支える基盤開発トーク」の様子について紹介しました。

各社のサービスの基盤プロダクト開発について社内外のエンジニアの工夫や知見が聞ける、とてもおもしろいイベントになりました。ご来場いただいたみなさま、ありがとうございました!


一休では、ともに良いサービスをつくっていく仲間を募集中です。

hrmos.co

カジュアル面談も実施しているので、お気軽にご応募ください。

www.ikyu.co.jp