Safie Engineers' Blog!

Safieのエンジニアが書くブログです

副作用と向き合う──Haskell に学ぶ宣言型プログラミングの考え方

はじめに

セーフィー株式会社 AI開発部 でテックリードを務めます橋本です。

本記事は、社内勉強会で取り上げた「プログラミングパラダイム」についての内容をまとめたものです。日常的に使っている Python や C++ といった言語でも、「副作用をどう扱うか」という視点の重要性を感じており、それをチーム内でも共有したいと思い勉強会のテーマとしました。

副作用とは、関数や処理が外部の状態に影響を与えたり、外部から影響を受けたりすることを指します。プログラムの規模が大きくなるほど、こうした副作用がバグの原因になりやすく、保守性にも影響します。この問題に対して、宣言型プログラミング、特に純粋関数型プログラミングの考え方は多くの示唆を与えてくれます。

本記事では、Haskell を例にしながら、命令型と宣言型の違い、副作用との向き合い方、そしてそれらの考え方をどのように他の言語に活かせるかを紹介していきます。

命令型プログラミングの特徴と課題

命令型の特徴

命令型プログラミング(Imperative Programming)は、「どのように処理を進めるか」を具体的な手順として記述するスタイルです。1950年代から1960年代初頭にかけて、コンピュータの普及とともに広まりました。

主な特徴

  • 状態の変化に基づく処理

    プログラム内の変数が繰り返し更新され、その変化によって処理の流れが決まります。例えば、ループ内で集計用の変数を加算していくような処理が代表的です。

  • 逐次実行(手続き型)

    命令は記述された順に上から下へ実行されます。制御の流れが明示的でわかりやすい一方、状態の追跡が難しくなることもあります。

  • 繰り返しや分岐による制御

    forwhile によるループ、ifswitch による条件分岐を使い、柔軟な制御フローを実現します。

このような特徴は、ノイマン型アーキテクチャ(プログラムとデータを同一メモリ空間に格納し、逐次実行するモデル)と親和性が高く、今日でも多くの言語の基本構造として使われています。

Pythonによる実装例

命令型プログラミングの特徴を具体的にイメージするために、Python を使った簡単な例を見てみましょう。

## 1から10までの合計を計算する
total = 0
for i in range(1, 11):
    total += i  # 状態(total)がループのたびに更新される

このような記述スタイルは、多くのプログラミング言語で標準的に用いられています。

オブジェクト指向との関係

オブジェクト指向プログラミング(OOP) は、命令型プログラミングのサブパラダイムと捉えることができます。なぜなら、OOP でも依然として「状態の変化」と「手続きの実行」によって処理が進むからです。

class Cat:
    def __init__(self, color):
        self.color = color # 状態を持つ

    def meow(self):
        print(f"I'm a {self.color} cat. Meow!")

命令型が抱える課題

命令型は柔軟で理解しやすい一方で、状態の追跡や副作用の管理に課題があります。

特に、変数の状態が頻繁に変わるようなコードでは、処理の意図やバグの原因を追いにくくなります。また、関数が外部に影響を及ぼす副作用を持つと、テストや再利用が難しくなり、保守性も低下します。

宣言型プログラミングの考え方

宣言型とは何か

宣言型プログラミング(Declarative Programming)は、「どのように処理を行うか」ではなく、「何をしたいのか」を記述するスタイルです。意図が明確で、状態や制御の管理が最小限で済むという特徴があります。

宣言型の特徴とアプローチ

宣言型プログラミングでは、以下のような設計上の特徴・原則が重視されます。

  • 不変性(Immutability)

    値や状態を途中で変更せず、すべての変数は定数として扱われます。

  • 純粋関数(Pure Function)

    同じ入力に対して常に同じ出力を返し、副作用を一切持たない関数です。外部の状態に依存せず、予測可能な挙動が保証されます。

  • 遅延評価と記述順の自由度

    計算は必要になるまで評価されないため、処理の記述順に意味が薄く、より宣言的な記述が可能です。

これらにより、状態や制御の複雑さを抑えながら、保守性・再利用性に優れたコードを書くことができます。

Haskellによる実装例

命令型のスタイルでよく書かれる処理が、宣言型の代表である Haskell でどのように記述されるのかを見ていきます。具体例を通じて、宣言型プログラミングの特徴である 不変性、純粋関数、遅延評価 がどのように現れるかを確認します。

total :: Int  -- totalはInt型と宣言
total = sum [1..10] -- totalを「1から10までのリストをsum関数で合計する関数」と定義

main :: IO ()  -- エントリポイントは main 関数。戻り値がIO () 型という宣言
main = print total  -- main関数の定義

sum 関数を再帰関数を使って書くこともできます。

sumArray :: [Int] -> Int
sumArray [] = 0  -- 基底ケース
sumArray (x:xs) = x + sumArray xs  -- 再帰ケース: 先頭の要素を残りの要素の合計に足す

main :: IO ()
main = print (sumArray [1..10])

Haskell に見る副作用の扱い

ここからは、Haskell を通じて、宣言型プログラミングにおける副作用との向き合い方を見ていきます。

Haskell は、副作用の扱いを明示的に設計に取り込んでいる言語であり、純粋関数型プログラミングの特徴を強く備えています。そのため、副作用を制御・分離する考え方を学ぶには最適な題材といえます。

副作用とは何か?

副作用(Side Effect)とは、関数や処理が、計算結果以外の影響をプログラム内外に及ぼすことを指します。例えば以下のようなものがあります。

  • 非決定性

    同じ入力でも結果が変わる処理。例:乱数生成、現在時刻の取得など。

  • 外部状態の変更

    例:ファイルへの書き込み、グローバル変数の更新、データベースの変更など。

  • 外部依存の参照

    例:ファイルの読み込み、ユーザー入力、環境変数の取得など。

コンテキストと持ち上げ

Haskell では、副作用をそのまま実行するのではなく、コンテキストと呼ばれる構造の中に閉じ込めて扱います。これにより、副作用や曖昧さを型レベルで明示し、安全かつ予測可能に制御できるようになります。

以下に、代表的なコンテキストの例を示します。

通常の型 コンテキスト付きの型 説明
Int [Int] リスト。複数の値を1つのまとまりとして扱う。
Int Maybe Int 値がある(Just)か無い(Nothing)かを区別できる型
Int IO Int 外部の副作用を伴う処理で得られる Int

通常の関数や値を、こうしたコンテキストの中でも使えるように変換する操作のことを、持ち上げ(lift)と呼びます。

この仕組みは、次のような 可換図式(commutative diagram) で表すことができます。可換図式とは、定義域と値域(型)をノード、関数やその関係性を矢印で示し、それらの構造的対応を視覚的に捉える図です。

例えば、以下の図では:

  • 下段が通常の関数適用 g :: A -> B
  • 上段が gf によって持ち上げた関数 f g :: f A -> f B

を表しています。

Functor / Applicative / Monad の枠組み

Haskell では、副作用や不確実性を含む値(コンテキスト付きの値)を安全に扱うために、Functor・Applicative・Monad という抽象的な枠組みが用意されています。これらは順にできることが広がっていきます。

Functor:コンテキスト内の値に関数を適用する

Functor は、f a というコンテキストに包まれた値に、通常の関数 a -> b を適用したいときに使います。

-- fmap の定義: (a -> b) と f a を受け取り、f b を出力する
fmap :: (a -> b) -> f a -> f b

可換図式を用いると、引数と出力を視覚的に整理できます。

通常の関数 g をコンテキストに包まれた値に適用する具体例を示します。リストに対するブロードキャスト演算は、Haskell 以外の言語でもなじみがあるのではないでしょうか。

g n = n * 2
main = do
    print $ fmap g [1, 2, 3]  -- [2, 4, 6]
    print $ fmap g (Just 5)  -- Just 10
    print $ fmap g Nothing  -- Nothing

Applicative:関数そのものをコンテキストに包んで適用する

Applicativeは、関数そのものがコンテキストに包まれている場合に使います。f (a -> b) という形の関数を f a に適用するための仕組みです。

pure :: a -> f a -- pure関数は、値aを、コンテキスト f a に持ち上げます

-- Applicative 演算子は、Functor の第一引数 a -> b が f (a -> b) に変わっただけです
(<*>) :: f (a -> b) -> f a -> f b

pure 関数で (*2) を持ち上げたあと、コンテキスト付の型に対して、演算子 <*> を使って作用させます。

main = do
    print $ pure (*2) <*> [1, 2, 3]  -- [2, 4, 6]
    print $ pure (*2) <*> Just 5  -- Just 10

Monad:コンテキスト間の連続した処理をつなげる

Monad は、値だけでなく処理そのものがコンテキスト付きであるときに、それらを連鎖的につなげるための仕組みです。

(>>=) :: m a -> (a -> m b) -> m b  -- バインド演算子の定義

このように Monad を使うと、途中で失敗したとき、安全に次の処理に進めるかを自動的に判断できます。

-- xが0ならNothing、それ以外ならJust xを返す関数
safeDiv x y = if y == 0 then Nothing else Just (x `div` y)

-- xが負ならNothing、それ以外ならJust xを返す関数
safeSqrt x = if x < 0 then Nothing else Just (floor (sqrt (fromIntegral x)))

-- xを2で割る関数
halve x = Just (x `div` 2)

result = safeDiv 10 2 >>= safeSqrt >>= halve  -- Just 1

他の言語で宣言型の考え方を活かす

命令型言語でも、宣言型の特徴が取り入れられています。

Python

リスト内包表記は、手続き的にforループを使うのではなく、宣言的にリストを作成します。

squares = [x**2 for x in range(10) if x % 2 == 0]

ラムダ関数とmap

add = lambda x, y: x + y
result = list(map(lambda x: x * 2, [1, 2, 3]))  # [2, 4, 6]

C++

C++17 では、値があるかどうかを型で扱える std::optional が導入されています。

std::optional<int> twice(int n) {
  return n > 0 ? std::optional<int>{n * 2} : std::nullopt;
}

C++23 では、Optional に対して Haskell のモナド的演算に相当する and_then が追加されました。

int main() {
  std::optional<int> o = 2;
  assert(o.and_then(twice).and_then(twice).value() == 8);
}

このように、副作用やエラーの可能性を含んだ処理を安全に連結できる仕組みが取り入れられています。

Rust

近年注目されている Rust も、宣言型プログラミングの考え方を強く取り入れられており、型システムによって副作用の制御が言語レベルで保証する設計がなされています。例えば、Option 型による明示的なエラーハンドリングによって副作用を局所化することができます。

fn double(n: i32) -> Option<i32> {
    Some(n * 2)
}

fn main() {
    let result = Some(5)
        .and_then(double)
        .and_then(double);  // Some(20)
}

このように、and_then を使って処理を連鎖させることで、状態の変更やエラー分岐を持たずにロジックを構築できます。

おわりに

本記事では、命令型と宣言型という二つのプログラミングパラダイムを比較しながら、特に副作用の扱いに注目してその設計思想を紹介しました。命令型では、状態の変化と逐次実行を通じて柔軟な処理が可能である一方、状態の追跡や副作用の管理が複雑化する傾向があります。

それに対し、宣言型プログラミングは処理の目的を明示的に記述し、副作用を抑えることで、コードの予測可能性と保守性を高めるアプローチです。Haskell を例に、コンテキストや持ち上げといった概念、そして Functor・Applicative・Monad という構造を通して、副作用を型として管理する手法を紹介しました。

こうした考え方は、純粋関数型言語に限らず、Python・C++・Rust をはじめとした他の言語でも応用可能です。日々の開発においても、関数の純粋性や状態の局所化を意識することで、より読みやすく、安全なコードを書く参考になれば幸いです。

© Safie Inc.