プログラミング/Haskell で学ぶ関数型プログラミング

関数型プログラミングの中で際立つ存在であるHaskellは、数学的な理論に基づいたクリーンでエレガントな構文を提供し、静的型付けにより安全性を高めています。 変数や関数は厳格な型に縛られ、それによってバグの発生を防ぎます。 関数の純粋性と不変性は、並行プログラミングにおいても威力を発揮し、副作用を最小限に抑えつつ高い効率を実現します。 また、Haskellの強力な型システムは、柔軟な多相性と型クラスによる拡張性を提供し、コードの再利用性を向上させます。 パターンマッチングは直感的で強力な制御構造を提供し、コードの可読性を向上させます。Haskellを学ぶことは、関数型プログラミングの理念やパラダイムを理解し、宣言的かつ効果的なコードを書くスキルを身につけることに繋がります。 この言語は学習者に洗練されたプログラミングのアプローチを提供し、新しい視点で問題に取り組むことができるようになります。

初級編 編集

Haskellの基本 編集

Haskellの基本的な構文 編集

Haskellは非常に清潔で数学的な構文を持っています。以下は基本的な構文のいくつかです。

-- コメントは "--" から始まります

-- 変数の定義
x :: Int
x = 5

-- 関数の定義
add :: Int -> Int -> Int
add a b = a + b

-- if式
maxValue :: Int -> Int -> Int
maxValue a b = if a > b then a else b

変数と型 編集

Haskellは静的型付け言語であり、変数は必ず型を持ちます。型はコロンで示されます。

-- 整数型
x :: Int
x = 42

-- 浮動小数点数型
y :: Double
y = 3.14

-- 文字型
char :: Char
char = 'A'

関数の定義と呼び出し 編集

関数の定義は以下のように行います。型注釈はオプションですが、コードの読みやすさのために推奨されます。

add :: Int -> Int -> Int
add a b = a + b

-- 関数の呼び出し
result :: Int
result = add 3 5

パターンマッチング 編集

パターンマッチングはHaskellの強力な機能の一つです。関数が引数に対して異なるパターンで振る舞うことができます。

saySomething :: Int -> String
saySomething 1 = "One"
saySomething 2 = "Two"
saySomething _ = "Other"  -- ワイルドカードパターン

上記の関数 saySomething は、引数が1なら"One"、2なら"Two"、それ以外なら"Other"を返します。ワイルドカード _ はどのパターンにもマッチします。

これらの基本的な構文を理解することで、Haskellでのプログラミングの基礎が築けます。次に、型クラスやリストなどの概念に進んでいくと、より高度なプログラムを書くことができます。

型システム 編集

型システムは、プログラミング言語において値の種類や性質を分類する仕組みです。Haskellの型システムは強力で静的なものであり、型エラーをコンパイル時に検出することができます。

型クラスの概念 編集

型クラスは、関連する型に対して一連の操作や振る舞いを定義する手段です。具体的な型が型クラスのインスタンスであれば、その型はクラスが定義する操作をサポートしています。例えば、Eq型クラスは等値性を定義し、Show型クラスは表示を定義します。

-- Eq型クラスのインスタンス
instance Eq MyType where
    (==) a b = ...

-- Show型クラスのインスタンス
instance Show MyType where
    show x = ...

型シグネチャと型推論 編集

型シグネチャは関数や変数に対してその型を明示的に指定するものです。一方で、Haskellは型推論により、コンパイラが自動的に型を導出することができます。型推論により、コードがより簡潔でありながらも型安全性を維持できます。

-- 型シグネチャ
add :: Int -> Int -> Int
add a b = a + b

-- 型推論によりInt型が導出される
result = add 3 5

多相性とポリモーフィズム 編集

多相性は、異なる型に対して同じ操作を行うことができる特性を指します。Haskellでは、多相性はジェネリクスやポリモーフィズムとも呼ばれます。これにより、関数やデータ型が異なる型に対して抽象的に動作できます。

-- 多相的な関数
identity :: a -> a
identity x = x

-- 型を指定せずに呼び出すことができる
resultInt = identity 42
resultChar = identity 'A'

多相性により、柔軟性が向上し、汎用的で再利用可能なコードを書くことができます。ポリモーフィズムは、型に依存せずに抽象的なプログラミングを可能にします。

リストとタプル 編集

リストとパターンマッチ 編集

リストはHaskellで最もよく使用されるデータ構造の一つであり、要素の順序付きのコレクションです。パターンマッチングはリストを効果的に操作する手段の一つです。

-- パターンマッチングを利用したリストの操作
sumList :: [Int] -> Int
sumList []     = 0         -- 空リストの場合
sumList (x:xs) = x + sumList xs  -- 先頭要素と残りのリストに分解

リスト内包表記 編集

リスト内包表記は、簡潔で表現力豊かなリストの生成や変換を行う手段です。条件を指定してリストを生成することができます。

-- リスト内包表記を利用したリストの生成
evens :: [Int]
evens = [x | x <- [1..10], even x]  -- 1から10までの偶数リスト

タプルの利用 編集

タプルは異なる型の要素を組み合わせる不変のデータ構造です。タプルは固定されたサイズを持ちます。

-- タプルの利用
person :: (String, Int, Char)
person = ("Alice", 25, 'F')

-- パターンマッチングを利用したタプルの操作
getAge :: (String, Int, Char) -> Int
getAge (_, age, _) = age

リストとタプルはHaskellで頻繁に使用され、リスト内包表記やパターンマッチングを含むこれらの概念を理解することで、データ操作や変換がより容易になります。

再帰と高階関数 編集

再帰の基本 編集

再帰は、関数が自分自身を呼び出すテクニックであり、Haskellの特に強力な機能です。再帰を使うことで、問題を単純化しやすくなります。

-- 再帰を利用した階乗の計算
factorial :: Integer -> Integer
factorial 0 = 1
factorial n = n * factorial (n - 1)

再帰のベストプラクティス 編集

  1. ベースケースの設定: 再帰関数を作成する際には、適切なベースケースを設定してください。これは再帰呼び出しを停止させる基準です。
    factorial :: Integer -> Integer
    factorial 0 = 1
    factorial n = n * factorial (n - 1)
    
  2. 末尾再帰の最適化: 末尾再帰の最適化を検討してください。Haskellのコンパイラは末尾再帰を最適化してくれるため、スタックオーバーフローを避けることができます。
    factorialTailRec :: Integer -> Integer
    factorialTailRec n = go n 1
      where
        go 0 acc = acc
        go m acc = go (m - 1) (m * acc)
    
  3. パターンマッチングの利用: パターンマッチングを活用して、再帰呼び出しを分かりやすくしてください。
    sumList :: [Int] -> Int
    sumList []     = 0
    sumList (x:xs) = x + sumList xs
    

再帰のユースケース 編集

  1. データ構造の探索や変換: 再帰を使用して、木構造やリストなどのデータ構造を再帰的に探索したり変換したりすることがあります。
    -- 二分木の要素の合計を計算
    data BinaryTree a = Leaf a | Node (BinaryTree a) (BinaryTree a)
    
    treeSum :: Num a => BinaryTree a -> a
    treeSum (Leaf x)     = x
    treeSum (Node l r) = treeSum l + treeSum r
    
  2. 数学的な計算: 再帰を使用して、数学的な計算や操作を行うことがあります。階乗やフィボナッチ数列などが典型的な例です。
    -- フィボナッチ数列の n 番目の要素を計算
    fibonacci :: Integer -> Integer
    fibonacci 0 = 0
    fibonacci 1 = 1
    fibonacci n = fibonacci (n - 1) + fibonacci (n - 2)
    
関数型プログラミングではループは敬遠され再帰が常用される
関数型プログラミングでは、ループよりも再帰がよく利用される傾向があります。これにはいくつかの理由があります。
  1. 不変性と参照透過性: 関数型プログラミングでは、変数の値が不変であり、関数の評価が参照透過性を持つことが重要です。再帰的な関数呼び出しにより、不変性と参照透過性を維持しやすくなります。
  2. 簡潔で抽象的なコード: 再帰はしばしば簡潔で抽象的なコードを書くのに適しています。再帰を使用すると、複雑な問題を単純で理解しやすい小さな部分に分割できます。
  3. 再帰が数学的帰納法に似ている: 数学的帰納法と同様に、再帰は問題を基本ケースと帰納ステップに分割する方法を提供します。これにより、問題全体に対するアプローチを構築しやすくなります。
  4. 高階関数との相性: 高階関数と再帰は相性が良く、再帰を使用することで高階関数を活用しやすくなります。高階関数を使用することで、より一般的で再利用可能なコードを書くことができます。

例えば、Haskellのリスト処理では、mapfilterfoldlfoldrなどの高階関数と再帰を組み合わせて非常に効果的なコードを書くことができます。


高階関数の導入 編集

高階関数は、他の関数を引数に取るか、関数を返す関数のことです。これにより、抽象度が高まり、汎用的な関数が作成できます。

-- 高階関数を利用した関数
applyTwice :: (a -> a) -> a -> a
applyTwice f x = f (f x)

map, filter, foldなどの高階関数の利用 編集

Haskellには標準でいくつかの高階関数が組み込まれており、これらはリストなどのデータ構造を操作する際に便利です。

-- map: リストの各要素に関数を適用する
squaredList :: [Int] -> [Int]
squaredList xs = map (\x -> x * x) xs

-- filter: 条件に合致する要素だけを抽出する
evenNumbers :: [Int] -> [Int]
evenNumbers xs = filter even xs

-- foldl: 左から順に畳み込む
sumList :: [Int] -> Int
sumList xs = foldl (\acc x -> acc + x) 0 xs

これらの高階関数は再帰と組み合わせて非常に強力な機能を提供し、コードをより簡潔かつ効果的にするのに役立ちます。

ラムダ式と無名関数 編集

ラムダ式の構文と利用 編集

ラムダ式は、無名の関数を作成するための式です。通常、簡潔な関数を即座に定義する場合に使用されます。

-- ラムダ式の例: 引数 x を 2 倍する関数
double = \x -> x * 2

カリー化と部分適用 編集

カリー化は、関数が複数の引数を取る際、各引数を一つずつ受け取り新しい関数を返す概念です。これにより、部分適用が可能になります。

-- カリー化された関数
add :: Int -> Int -> Int
add x y = x + y

-- 部分適用の例: add 関数に 5 を部分適用して新しい関数を作成
addFive :: Int -> Int
addFive = add 5

ラムダ式とカリー化により、関数の柔軟性が向上します。これらの概念を組み合わせて、簡潔で再利用可能な関数を作成できます。

高階関数のベストプラクティス 編集

  1. 型シグネチャの利用: 高階関数を定義する際には、明示的な型シグネチャを付けることでコードの理解が容易になります。
    applyTwice :: (a -> a) -> a -> a
    applyTwice f x = f (f x)
    
  2. 関数合成の活用: . (関数合成演算子) を使用して関数を合成することで、コードを簡潔にすることができます。
    sumOfSquaresEven = sum . map (\x -> x * x) . filter even
    
  3. ラムダ式の活用: 無名関数やラムダ式を使用して、簡潔かつインラインで関数を定義することができます。
    squaredList = map (\x -> x * x)
    
  4. 一般化と抽象化: 高階関数を一般的で抽象的な形で設計し、再利用可能で柔軟なコードを目指しましょう。
    applyOperationToList :: (a -> b) -> [a] -> [b]
    applyOperationToList _ []     = []
    applyOperationToList f (x:xs) = f x : applyOperationToList f xs
    

これらのベストプラクティスを守ることで、再帰と高階関数を用いたコードが理解しやすく、保守性が向上します。

高階関数のユースケース 編集

  1. データ変換: 高階関数を使用して、リストなどのデータ構造を変換したりフィルタリングしたりすることがあります。
    -- リストの各要素を2倍する
    doubleList :: [Int] -> [Int]
    doubleList = map (\x -> x * 2)
    
  2. 抽象化: 高階関数を使用して、コードを抽象化し、一般的で再利用可能な関数を作成することがあります。
    -- リストの各要素に関数を適用する高階関数
    applyToAll :: (a -> b) -> [a] -> [b]
    applyToAll f xs = map f xs
    
  3. 畳み込み (Folding): 高階関数を使用して、リストなどのデータ構造を畳み込んで新しい値を生成することがあります。
    -- リストの合計を計算する
    sumList :: [Int] -> Int
    sumList = foldl (+) 0
    

これらのユースケースは再帰と高階関数の特性を活かして、コードを効果的に構造化し、柔軟かつ再利用可能なプログラムを作成するのに役立ちます。

Haskellの純粋性
Haskellは純粋な関数型プログラミング言語として知られており、その特徴の一つが「純度」です。Haskellの純度にはいくつかの側面があります。
純粋な関数
Haskellの関数は純粋であるという特性があります。つまり、同じ入力に対して常に同じ出力を返し、外部の状態に影響を与えません。これは参照透過性の概念に基づいています。
不変性
Haskellにおいて変数は不変です。変数が一度束縛されたら、その値は変更されません。これにより、プログラムの挙動が予測可能で、理解しやすくなります。
副作用の分離
Haskellでは、副作用(Side Effect)が厳密に制御されています。純粋な関数は副作用を持たないため、副作用が発生するコードは特定の箇所で限定され、その他のコードは純粋な関数として扱われます。
遅延評価
Haskellは遅延評価を採用しています。これは、式が実際に評価されるまで値が計算されないという考え方です。これにより、必要な時点でのみ計算が行われ、効率的な評価が可能になります。

これらの純粋性により、Haskellのプログラムは一貫性があり、理解しやすく、バグの発生を減少させる効果があります。ただし、一部のI/O操作や状態変更を必要とする処理は、中級編で紹介するモナドと呼ばれる概念を通じて制御されます。モナドを使用することで、外部の副作用を純粋な関数型プログラミングの枠組み内で取り扱うことができます。


中級編 編集

Haskell固有の特徴と一般的な関数型プログラミングの原則 編集

Haskellは関数型プログラミングの中で特に純粋な言語として知られていますが、Haskell固有の特徴と一般的な関数型プログラミングの原則にはいくつかの共通点と差異があります。以下は、その主な差異と共通点です。

Haskell固有の特徴
純粋性と参照透過性
Haskellは純粋な関数型プログラミング言語であり、純粋性と参照透過性が徹底的に強調されています。関数は入力に対して常に同じ出力を返し、副作用を持たないことが期待されます。
型推論
Haskellは強力な静的型付けを持ち、型推論によって多くの場合、型を明示的に宣言せずにプログラムを書くことができます。これにより、型安全性が向上し、コードがより洗練された形で書けます。
モナド
Haskellではモナドという概念が重要です。モナドは副作用を持つ計算を制御する手法を提供します。これにより、純粋性を保ちながらも I/O 操作や状態変更などの副作用を扱うことが可能になります。
遅延評価:Haskellでは遅延評価が採用されています。これは、値が必要になるまで評価が遅延され、必要な時点でのみ計算が行われるという考え方です。これにより、無駄な計算を避けつつ効率的にプログラムを記述できます。
一般的な関数型プログラミングの原則
高階関数
関数を第一級オブジェクトとして扱い、高階関数(関数を引数や戻り値として取る関数)を頻繁に使用することが関数型プログラミングの重要な原則です。これにより、柔軟で抽象的なコードが書けます。
不変性
関数型プログラミングでは、変更可能な状態を避け、不変性を重視します。Haskellもこれを尊重していますが、他の関数型プログラミング言語でも同様の原則が適用されます。
関数合成
関数を組み合わせて新しい関数を作る関数合成が一般的です。関数合成により、複雑な処理を小さな単位に分割しやすくなります。
再帰
再帰は関数型プログラミングにおいてよく使用される手法です。Haskellも再帰をサポートし、これを利用してデータ構造の探索や変換などを行います。

これらの原則は、Haskellに限らず関数型プログラミングの一般的な原則として共有されています。ただし、Haskellはその純粋性や型推論、モナドといった独自の特徴によって、他の関数型プログラミング言語と差異が生まれています。

気がついた方もいらしゃるかもしれませんが、初級編ではつとめて一般的な関数型プログラミングの原則を取り上げました。

中級編では、段階的にHaskell固有の特徴を段階的に増やしていきます。

モジュールとパッケージ 編集

モジュールの作成と利用 編集

Hackageからのパッケージのインストール 編集

モナド 編集

モナドの基本概念 編集

Maybeモナド、リストモナドなどの具体例 編集

モナド変換子 編集

IOモナド 編集

Haskellにおける入出力の基本 編集

do構文の利用 編集

モナドを使った副作用の扱い方 編集

型クラスの詳細 編集

Functor、Applicative、Monadなどの型クラスの詳細な理解 編集

型クラスのインスタンスの作成 編集

パフォーマンスと最適化 編集

ストリクト評価と遅延評価 編集

パフォーマンスの向上のための最適化テクニック 編集

上級編 編集

ファンクタ、アプリカティブ、モナドトランスフォーマー 編集

ファンクタとアプリカティブの一般化 編集

モナドトランスフォーマーの利用 編集

パーサーコンビネーター 編集

パーサーコンビネーターの概念 編集

Megaparsecなどのライブラリの利用 編集

STM (Software Transactional Memory) 編集

トランザクショナルメモリの基本原則 編集

STMを利用した並行プログラミング 編集

データ構造とアルゴリズム 編集

木構造やグラフ構造などのデータ構造の利用 編集

高度なアルゴリズムの実装 編集

グラフィカルユーザーインターフェース (GUI) の構築 編集

HaskellでGUIを作成するためのライブラリの利用 編集


関数型プログラミング言語便覧 編集

関数型プログラミング言語には多くの種類があります。以下に、代表的な関数型プログラミング言語をいくつか紹介します。

Haskell

純粋な関数型プログラミング言語であり、強い静的型付けを持ちます。遅延評価やモナドなどの機能を持ち、高度な抽象化を実現することができます。

OCaml

関数型プログラミング言語としては珍しい、強い静的型付けを持つ言語です。高階関数やカリー化、パターンマッチングなどの機能があり、特に数値計算やコンパイラなどの分野で活躍しています。

Scala

Javaプラットフォーム上で動作する関数型プログラミング言語であり、オブジェクト指向プログラミングとの融合が特徴的です。静的型付けやジェネリクス、パターンマッチングなどの機能があり、分散処理フレームワークであるApache Sparkにも採用されています。

Clojure

Lispの方言であり、JVM上で動作する関数型プログラミング言語です。マクロや不変性データ構造、STMなどの機能があり、コンカレントプログラミングに特化しています。

F#

.NET Framework上で動作するマルチパラダイム言語であり、関数型プログラミングに特化しています。静的型付けやタプル、パイプライン演算子などの機能があり、C#と組み合わせることで強力なプログラミング環境を構築することができます。

以上が、代表的な関数型プログラミング言語の一部です。それぞれの言語には特徴的な機能があり、プログラムの目的に応じて選択することが重要です。

関数型プログラミング言語用語集 編集

以下に、関数型プログラミング言語で用いられる主要な用語を紹介します。

  • 純粋関数(Pure function)  — 外部状態を変更しない、副作用のない関数のことを指します。同じ引数に対しては常に同じ値を返し、予測可能でテストしやすいコードを実現するために重要な概念です。
  • 高階関数(Higher-order function)  — 関数を引数として受け取ったり、戻り値として返したりする関数のことを指します。関数を値として扱えることで、より抽象的なコードを書くことができます。
  • カリー化(Currying)  — 引数を複数取る関数を、引数を一つずつ取る関数の連続呼び出しに変換することを指します。カリー化することで、部分適用や高階関数の組み合わせなどが容易になります。
  • モナド(Monad)  — 副作用のあるコードを扱う際に使用される、抽象的な概念のことを指します。副作用のあるコードを純粋関数と同様に扱えるようにすることで、コードの再利用性や保守性を高めることができます。
  • 遅延評価(Lazy evaluation)  — 値が必要になるまで評価を遅延することを指します。必要な値のみを計算することで、無駄な計算を避けることができます。
  • パターンマッチング(Pattern matching)  — 値のパターンに合わせて処理を分岐することを指します。分岐処理を直感的に記述することができ、複雑な分岐処理を簡潔に表現することができます。
  • 不変性(Immutability)  — 変更不可能な値を扱うことを指します。変更不可能な値はスレッドセーフであり、同じ値を共有することでオブジェクトの生成を減らすことができます。
  • 畳み込み(Folding)  — リストや配列などの集合に対して、要素を順次処理していく操作を指します。畳み込みを用いることで、集合に対する繰り返し処理を一行で表現することができます。
  • ジェネリクス(Generics)  — 型の抽象化を可能にすることを指します。ジェネリクスを用いることで、型安全性を保ちつつ汎用的なコードを記述することができます。
  • カテゴリー理論(Category theory)  — 関数型プログラミングにおける理論的な基盤となる数学的な分野の一つです。関数を対象とし、関数同士の合成を射として扱うことで、関数の結合律や恒等射などの性質を考察します。
  • 型推論(Type inference)  —

コンパイラが変数や関数の型を自動的に推論する仕組みを指します。型推論を用いることで、コードの可読性を向上させ、コードを短くかつ保守性が高いものにすることができます。

  • 再帰(Recursion)  — 自身を呼び出すことで、同じ処理を繰り返すことを指します。再帰を用いることで、反復処理に比べてコードの記述が容易になる場合があります。
  • ラムダ計算(Lambda calculus)  — 関数の数学的なモデルとして用いられる言語であり、関数型プログラミングの基盤となっています。関数をラムダ記法で表現し、関数適用や簡約を行うことで、関数の意味論を形式的に考察します。
  • ストリーム(Stream)  — 無限に続くデータ列を表現するための概念で、遅延評価と相性が良いです。ストリームを用いることで、無限のデータ列をコンパクトに表現することができます。
  • クロージャー(Closure)  — 関数とその関数が参照する変数の束縛を持つオブジェクトのことを指します。クロージャーを用いることで、関数が参照する変数を外部から変更されないようにすることができます。また、クロージャーを用いることで、関数を生成する関数を記述することができます。

以上が、関数型プログラミング言語で用いられる主要な用語の一部です。これらの概念を理解することで、関数型プログラミングの基礎を理解することができます。

まとめ 編集

関数型プログラミング言語は、プログラムを関数の集合として捉えるプログラミングパラダイムであり、変数の再代入が禁止されています。代わりに、関数の適用によって値を変更することができます。関数型プログラミング言語の代表的な言語として、HaskellやOCaml、Scalaなどがあります。

関数型プログラミング言語では、不変性や再帰などの概念が重要視されます。また、高階関数やカリー化、遅延評価などの機能があり、より汎用的な関数を実現することができます。

関数型プログラミング言語は、並列処理や分散処理など、複雑な処理を簡単に扱うことができる点が魅力的です。また、プログラムの保守性や再利用性が高くなるという利点もあります。

しかし、関数型プログラミング言語は、オブジェクト指向プログラミング言語とは異なる構文や機能を持つため、学習コストが高いというデメリットもあります。また、一部の処理では、手続き型の方が効率的であることがあるため、すべての処理に関数型プログラミング言語を利用するわけではありません。

以上が、関数型プログラミング言語の基本的な概念と特徴です。関数型プログラミング言語は、プログラムの抽象化や再利用性を高めるための有力なツールの一つであり、今後ますます注目されることが予想されます。

コードギャラリー 編集

行列 編集

-- モジュールの宣言
module Main where

import Data.List (intercalate)

-- 行列型の定義
data Matrix a = Matrix { matData :: [[a]] }

-- 行列の演算
instance (Num a) => Num (Matrix a) where
  (+) = addMatrix
  (-) = subtractMatrix
  (*) = multiplyMatrix
  abs = undefined
  signum = undefined
  fromInteger = undefined

-- 行列の加算
addMatrix :: (Num a) => Matrix a -> Matrix a -> Matrix a
addMatrix (Matrix mat1) (Matrix mat2) = Matrix $ zipWith (zipWith (+)) mat1 mat2

-- 行列の減算
subtractMatrix :: (Num a) => Matrix a -> Matrix a -> Matrix a
subtractMatrix (Matrix mat1) (Matrix mat2) = Matrix $ zipWith (zipWith (-)) mat1 mat2

-- 行列の乗算
multiplyMatrix :: (Num a) => Matrix a -> Matrix a -> Matrix a
multiplyMatrix (Matrix mat1) (Matrix mat2) = Matrix $ [[sum $ zipWith (*) row col | col <- transpose mat2] | row <- mat1]
  where
    transpose = foldr (zipWith (:)) (repeat [])

-- 行列の除算
instance (Fractional a) => Fractional (Matrix a) where
  (/) = divideMatrix
  fromRational = undefined

-- 行列の除算
divideMatrix :: (Fractional a) => Matrix a -> Matrix a -> Matrix a
divideMatrix (Matrix mat) (Matrix scalarMat) = Matrix $ zipWith (zipWith (/)) mat scalarMat

-- 行列の表示
instance (Show a) => Show (Matrix a) where
  show = matrixToString

-- 行列の表示
matrixToString :: (Show a) => Matrix a -> String
matrixToString (Matrix mat) = intercalate "\n" $ map (unwords . map show) mat

-- メイン関数
main :: IO ()
main = do
  let mat1 = Matrix [[1, 2], [3, 4]]
      mat2 = Matrix [[5, 6], [7, 8]]

  -- 加算
  putStrLn $ matrixToString mat1 ++ "\n +\n" ++ matrixToString mat2 ++ "\n =\n" ++ matrixToString (mat1 + mat2) ++ "\n"

  -- 減算
  putStrLn $ matrixToString mat1 ++ "\n -\n" ++ matrixToString mat2 ++ "\n =\n" ++ matrixToString (mat1 - mat2) ++ "\n"

  -- 乗算
  putStrLn $ matrixToString mat1 ++ "\n *\n" ++ matrixToString mat2 ++ "\n =\n" ++ matrixToString (mat1 * mat2) ++ "\n"

  -- 除算
  putStrLn $ matrixToString mat1 ++ "\n /\n" ++ matrixToString mat2 ++ "\n =\n" ++ matrixToString (mat1 / mat2) ++ "\n"