ミツモア Tech blog

「ミツモア」を運営する株式会社ミツモアの技術ブログです

宣言的って何なん?

こんにちは🎅ミツモアエンジニアの佐藤 (@tkmsgr) です。

言葉だけが先行して意味がなおざりなってる用語って、IT業界にそこそこありませんか?

この記事では、その1つ(だと思っている)である宣言的(declarative) という考え方について皆さんと一緒に探っていきたいと思います。

よくある例と説明

まずは、よくありがちなモヤっとする解説例を眺めていきます。

// 命令的 「どうするか」を記述
let result = 0;
for (let i = 0; i < numbers.length; i++) {
  result += numbers[i]
}

// 宣言的 「何をするか」を記述
const result = numbers.reduce((sum, n) => sum + n, 0)
// 命令的 「どうするか」を記述
const merged = {};
for (const key in obj1) {
  merged[key] = obj1[key]
}
for (const key in obj2) {
  merged[key] = obj2[key]
}

// 宣言的 「何をするか」を記述
const merged = { ...obj1, ...obj2 }
// 命令的 「どうするか」を記述
let message;
if (score >= 80) {
  message = "優秀";
} else if (score >= 60) {
  message = "合格";
} else {
  message = "不合格";
}

// 宣言的 「何をするか」を記述
const message = 
  score >= 80 ? "優秀" :
  score >= 60 ? "合格" :
  "不合格"
// 命令的 「どうするか」を記述
const div = document.createElement('div')
div.className = 'container'
div.textContent = 'Hello'
document.body.appendChild(div);

// 宣言的 「何をするか」を記述
<div className="container">Hello</div>

命令的とは「どうするか」を記述し、宣言的とは「何をするか」を記述する

という説明でなるほど!と思った人はあまりいないのではないでしょうか?

少なくとも私はモヤモヤ感だけが募りました。

もちろん、この説明自体は間違っていないとは思います。

しかしあくまで「方向性」にしか過ぎず、それが余計に分かりづらくしているのではないかな?と個人的に思っています。

そこで、私なりの結論を2つほど用意しました。

結論その1

特徴1. 文を含まず式として記述してあること(普遍的・絶対的)

// 命令的 「文構造を含む」
let result = 0;
for (let i = 0; i < numbers.length; i++) {
  result += numbers[i]
}

// 宣言的 「式で完結」
const result = numbers.reduce((sum, n) => sum + n, 0)

おそらくこの特徴は明瞭でかなり分かりやすいと思います。

この解釈で先ほどの例をみると、全て当てはまっているのではないでしょうか?

また、宣言的な書き方では式として完結しているため、変数が const で宣言されていることが見てとれます。


ちなみに、JSXも式に変換されます。

// JSX
<div className="container">Hello</div>

// 変換するとこんな感じに
import { jsx } from 'react/jsx-runtime'

jsx('div', { 
  className: 'container', 
  children: 'Hello' 
})

image.png

式のメリット

宣言的 というのは式で書くことがとりあえず1つの条件になっていそうだな?というのが分かりました。

ところで、 宣言的 というのは大体のドキュメントにおいて好意的なものとして書かれていますよね。

であるならば、式で書くこと自体によるメリットが何かあるのかもしれません。

型としての表現できる

おそらくこれは分かりやすいのメリットだと思います。

式は評価された値を型として記述できるので、結果を型として表現できます。

型として表現することで、静的な記述の段階である程度の整合性をエラーで弾くことで実現できたりします。

また、概要を一瞥できるマーカーとなる側面も大きいと思います。

try {} catch {} のような制御構造であっても effect-tsではEffect , fp-ts や Haskell では Either, Rust では Result と表現できたりするかと思います。

副作用を含むのであれば IO なども有名ですね。

可搬性

文だと値ではないので、式しか受け付けないような場所には記述できなかったりします。

このような場合、その文構造を含む関数として記述しなおし式に変換すれば渡すことができます。

// ❌ 文は関数の引数として渡せない
let result;
if (score >= 80) {
  result = "優秀"
} else {
  result = "不合格"
}

// これを関数に渡したい...でも渡せない
someFunction(result) // 結果の値しか渡せない

// 文を渡すには、関数でラップする必要がある
function judge(score) {
  let result
  if (score >= 80) {
    result = "優秀"
  } else {
    result = "不合格"
  }
  return result
}

someFunction(judge) // これでようやく渡せる

// ✅ 式なら直接渡せる
const judge = (score) => score >= 80 ? "優秀" : "不合格"

someFunction(judge) // 式なのでそのまま渡せる

合成可能性

先ほど紹介した可搬性に付随する内容です。

式ならば可搬性が高いので関数の引数に渡すことができます。

これを連続することによって、処理を組み合わせて合成することができます。

// ❌ 文は合成できない - 順番に実行するしかない
let step1Result
if (data > 0) {
  step1Result = data * 2
} else {
  step1Result = 0
}

let step2Result
if (step1Result > 10) {
  step2Result = step1Result + 5
} else {
  step2Result = step1Result
}

let finalResult
if (step2Result < 100) {
  finalResult = step2Result * 3
} else {
  finalResult = step2Result
}

// この3ステップを1つにまとめたい...でも文だと合成できない
// 関数化するしかない
function process(data) {
  let step1Result
  if (data > 0) {
    step1Result = data * 2
  } else {
    step1Result = 0
  }
  
  let step2Result
  if (step1Result > 10) {
    step2Result = step1Result + 5
  } else {
    step2Result = step1Result
  }
  
  let finalResult
  if (step2Result < 100) {
    finalResult = step2Result * 3
  } else {
    finalResult = step2Result
  }
  
  return finalResult
}

// ✅ 式なら合成できる
const step1 = (data) => data > 0 ? data * 2 : 0
const step2 = (x) => x > 10 ? x + 5 : x
const step3 = (x) => x < 100 ? x * 3 : x

// 合成して1つの処理にできる
const process = (data) => step3(step2(step1(data)))

置き換え可能性、参照等価性

おそらくこれが一番のメリットなんじゃないかなと個人的には思います。

置き換え可能性はパフォーマンス最適化と大きな繋がりがあります。

こちらは型の表現可能性と可搬性に大きく関係(付随?)していそうです。

型理論が基盤としておく考え方の1つに置き換え可能な順序付けが挙げられます。

Bが期待される場所でAを使用できることを A は B のサブタイプといい A <: B と記述したりします。

type Animal = {
  name: string
  eat: () => void
}

type Dog = {
  name: string
  eat: () => void
  bark: () => void
}

// Dog <: Animal
// 「DogはAnimalのサブタイプ」

function feed(animal: Animal) {
  animal.eat()
}

const dog: Dog = {
  name: "Pochi",
  eat: () => console.log("eating"),
  bark: () => console.log("barking")
}

feed(dog) // ✅Animal を期待する場所に Dog を置ける

置き換え可能というのは、置き換えても着目している振る舞いが変わらないということを指し、副作用を含む場合はわりと恣意的な要素を含みます。

それに対して純粋関数であれば、結果の値が一致していれば過程は問わず同じものとみなして置換可能とすることが多いです。

結論その2

特徴2. 常識的で直感的なインターフェースにすること(恣意的・相対的)

特徴1. を必要条件としたら、特徴2. は十分条件(に向かう方向性)にあたります。

なるべく普遍的で一般的な用語を使用してそこから想起される動作を提供するといった、一般的なAPI設計に求められる境界そのものの性質を指します。

特徴1. はわかりやすい基準点がありましたが、この特徴2. にはそれがありません。

読み手の解釈に何がわかりやすいかは常に分かれてしまうので、これは望ましい方向性と解釈するほうが自然です。

この解釈は冒頭で述べた「よくある説明」にもう少しやることを明確化したバージョンであるといえると思います。

わかりやすい例

最たる例として Prisma が挙げられます。

// Kysely: SQLの構造をそのまま反映(より命令的)
const users = await db
  .selectFrom('users')
  .selectAll()
  .where('age', '>=', 18)
  .where('status', '=', 'active')
  .execute()

// Prisma: SQLから抽象化された(より宣言的)
const users = await prisma.user.findMany({
  where: {
    age: { gte: 18 },
    status: 'active'
  }
})

Prisma のほうが Kysely に比べて、SQLの知識を必要としない直感的なインターフェースになっていることがわかります。

オブジェクトを操作する、というより値を設定していくという感覚に近い記述方法です。

より宣言的になることのデメリット・注意点

とはいえ、宣言的にすることはメリットばかりではありません。

より宣言的にするためには、必ず抽象化が伴います。

抽象化して置き換えたものが、元のものより表現力が落ちることがあります。

これは安全性や疎結合性とのトレードオフになっています。

以下、抽象化して置き換えたものが元のものより表現力が落ちる例を見ていきます。

  • 補足)

    prisma は宣言的なインターフェースを標榜しているため、それに伴って表現力が不足するのは避けられないことで仕方がないことではあります。なので 私を嫌いになっても prisma を嫌いにならないでください😢

// 例1: WITH句(CTE)

// ✅ Kysely: CTEで複雑なクエリを段階的に構築
const result = await db
  .with('active_users', (db) =>
    db
      .selectFrom('users')
      .selectAll()
      .where('status', '=', 'active')
  )
  .selectFrom('active_users')
  .leftJoin('posts', 'posts.user_id', 'active_users.id')
  .select((eb) => [
    'active_users.id',
    'active_users.name',
    eb.fn.count('posts.id').as('postCount')
  ])
  .groupBy(['active_users.id', 'active_users.name'])
  .having((eb) => eb.fn.count('posts.id'), '>', 5)
  .execute()

// ❌ Prisma: CTEは表現できない
// 例2: UNION / UNION ALL

// ✅ Kysely: 異なるテーブルの結果を結合
const recentActivity = await db
  .selectFrom('posts')
  .select([
    sql<string>`'post'`.as('type'),
    'id',
    'created_at'
  ])
  .unionAll(
    db
      .selectFrom('comments')
      .select([
        sql<string>`'comment'`.as('type'),
        'id',
        'created_at'
      ])
  )
  .orderBy('created_at', 'desc')
  .limit(20)
  .execute()

// ❌ Prisma: UNIONは表現できない

SQL自体は長年にわたって互換性を保ちながら提供されている、変更の余地が少ない安定したインターフェースです。

つまり、抽象化することによる安全性や疎結合性の向上による恩恵が非常に少ないといえます。

より宣言的にするには

ここまでメリットとデメリットを見てきました。

とはいえ、普段の実装においてはメリットの部分が大きいのではないでしょうか。

フロントエンド周りで JQuery を使わず React, Vue などを使うのが主流になっているのも、それらが宣言的なロジックを提供している恩恵が大きいからだと思います。

どう実装を意識すれば宣言的になるか

(特に制御文)をどこに配置するかを意識するのがポイントかもしれません。

JavaScriptでは文を内包できる式は関数だけなので、必然的に関数を使うことになります。

毎回外側で制御文を書いているならば、その文を関数の内側に持っていく(もしくは渡す側に持たせる)ことを検討してみるといいかもしれません。

// ❌ 命令的: 環境ごとに条件分岐が散らばる
async function fetchUserImperative(userId: number): Promise<User> {
  const response = await fetch(`/api/users/${userId}`)
  
  if (response.status === 404) {
    throw new NotFoundError(userId)
  }
  if (!response.ok) {
    throw new NetworkError(response.statusText)
  }
  
  return await response.json()
}

const userIds = [123, 456, 789]

for (const userId of userIds) {
  try {
    const user = await fetchUserImperative(userId)
    
    // 環境判定: 成功時
    if (process.env.NODE_ENV === 'production') {
      updateUI(user)
    } else {
      console.log('[DEV] ユーザー取得成功:', user)
      updateUI(user)
    }
  } catch (error) {
    // 環境判定: エラー時
    if (process.env.NODE_ENV === 'production') {
      if (error instanceof NotFoundError) {
        showToast('ユーザーが見つかりませんでした')
      } else {
        showToast('エラーが発生しました')
        sendToMonitoring(error)
      }
    } else {
      console.error('[DEV] エラー詳細:', error.stack)
      showDevErrorPanel(error)
    }
  }
}
// 使う側: ❌毎回環境判定ロジックを書く必要がある
// ✅ 宣言的: 環境ごとのハンドラを一箇所で定義
type Handlers<T, E extends Error> = {
  onSuccess: (data: T) => void
  onError: (error: E) => void
}

async function fetchUser(
  userId: number,
  handlers: Handlers<User, FetchError>
): Promise<void> {
  const { onSuccess, onError } = handlers
  
  try {
    const response = await fetch(`/api/users/${userId}`)
    
    if (response.status === 404) {
      throw new NotFoundError(userId)
    }
    if (!response.ok) {
      throw new NetworkError(response.statusText)
    }
    
    const data = await response.json()
    onSuccess(data)
  } catch (error) {
    onError(error as FetchError)
  }
}

// 開発環境用
const devHandlers: Handlers<User, FetchError> = {
  onSuccess: (user) => {
    console.log('[DEV] ユーザー取得成功:', user)
    updateUI(user)
  },
  onError: (error) => {
    console.error('[DEV] エラー詳細:', error.stack)
    showDevErrorPanel(error)
  }
}

// 本番環境用
const prodHandlers: Handlers<User, FetchError> = {
  onSuccess: (user) => {
    updateUI(user)
  },
  onError: (error) => {
    if (error instanceof NotFoundError) {
      showToast('ユーザーが見つかりませんでした')
    } else {
      showToast('エラーが発生しました')
      sendToMonitoring(error)
    }
  }
}

// 環境判定は一箇所だけ
const handlers = process.env.NODE_ENV === 'production' 
  ? prodHandlers 
  : devHandlers

// 使う側: 環境判定なし、シンプル
const userIds = [123, 456, 789]

for (const userId of userIds) {
  fetchUser(userId, handlers)
}
// User以外のXXX用の createFetchXXX のような factory などを作っておけば
// 他の fetch のところでも同一の制御ロジックを持つ handler を使い回せる 

このような「制御文を隠蔽して、振る舞いを外から注入する」パターンは、身近なところでは PromiseIterator でも使われています。 コードの中で同じ制御文が繰り返し現れたら、それを抽象化して宣言的な記述にするタイミングかもしれません。

で、結局のところ宣言的って何なん?

複雑さを内部に閉じ込めて、インターフェースを使い手の自然な認識になるべく寄せたいなあ、という願望に過ぎないのかもしれません😊