Introduction
関数という概念はGolangでも馴染みがあるし、普通のプログラミングと何が違うの?と感じる人は少なくないと思います。
でも、実はGolangのような所謂「手続型プログラミング」と「関数型プログラミング」は全く異なるパラダイムです。
関数型プログラミングを理解すると、プログラミングを別の角度から見ることができるようになり、視野がグッと広がります。
今日は、Gopherの皆さんに、関数型プログラミングの世界について紹介します。
※この記事では計算理論や圏論などの領域には触れません。また、わかりやすさを優先し、厳密ではない言葉の使い方をしている箇所がありますが、ご容赦ください。
メンタリズムの違い
Golangは所謂、手続き型言語に該当します。
手続き型言語の元祖であるC言語は、アセンブリに変わるシステムプログラミングのための高水準言語として生まれました。
つまるところ、手続き型言語においてプログラムとは「コンピュータを制御するためのもの」というメンタリズムなわけです。
一方で、関数型パラダイムは代数学や計算理論の分野から生まれました。
そのため、関数型言語では「プログラムとは計算可能な関数の集合である」というメンタリズムを持っています。
関数型プログラミングのメリット
- 不変性
- 保守性
- 型による堅牢性
Golangの関数は関数ではない!
ところで、皆さんが最初に「関数」という概念を知ったのはいつでしょうか?
多くの人は、恐らく初等数学で習った f(x) のようなヤツだと思います。
関数には何か計算式が定義されていて、入力を受け取って値を返却する、というものですね。
例えば以下の関数は、入力された数値を二乗して返却する関数です。
f(x) = x^2
関数の根本はこれです。
Golang では概念の類似性から便宜上「関数」と表現されているだけで、本来の「関数」ではありません。
なぜならば、Goの関数は呼び出しても何も結果が返ってこなかったり、はたまた突然 panic を起こしてしまったりもします。
ここでいう本当の意味での関数を関数として取り扱うのが関数型言語であり、関数型プログラミングです。
Golangの関数
例えば、goには標準出力を行うfmt.Println
関数があります。
package main
import "fmt"
func main() {
fmt.Println("Hello Workd!")
}
これは本来の意味での関数でしょうか?
答えは「No」です。
これはコンピュータ(外の世界)に対する「文字を出力しなさい」という命令にすぎず、「何かを計算して結果を返す」という考えからは根本的に異なっています。
副作用の扱い
関数型プログラミングでは、IO操作や、外部へのアクセス、状態の変更など、計算不可能な要素は、計算可能性を追求する関数型の考えと競合してしまいます。
このような「関数の外部に対して行う予測できない変更」は副作用と表現され、関数からは排除されます。
ここまで聞くと、「関数型言語ってすごく不便じゃない?」と思われるかもしれません。
しかし実際にはそんなことはなく、関数型言語でもそのような副作用を扱うための方法がちゃんと存在します。
(「アクション・モナド」という画期的な仕組みがあるのですが、これについては今回は触れません)
関数型プログラミングの真髄は、このように純粋な関数と副作用を明確に分けて考えるというところにあると個人的には考えています。
副作用がないと何が良いのか
副作用の代名詞とも言えるOOPでは、カプセル化の制約によりオブジェクト内部の状態は隠蔽され、オブジェクトの状態によってメソッドは振る舞いが変化します。
public class Example {
private int counter = 0;
public void incrementCounter() {
counter++;
}
public int getCounter() {
return counter;
}
}
このようなクラスはテストが状態に依存してしまい、テストの可用性が低下するだけでなく、再現性や検証が難しくなる要因になります。
一方で、副作用が無い関数は同じ入力に対して必ず同じ出力を返すため、関数が常に予測可能な振る舞いを示します。
入力と出力の間に明確な対応があるため、テストの可用性と、信頼性が向上します。
特に、副作用がない関数のことを「純粋関数」と呼んだりします。
Golangでも副作用の少ない関数を書こう
レシーバを直接変更するタイプのメソッドを避ける
type MyInt int
func (myInt *MyInt) Add(n int) {
*myInt += +1
}
ではなく
type MyInt int
func (myInt MyInt) Add(n int) int {
return int(myInt) + n
}
や、
func Add(x, y int) int {
return x + y
}
シングルトンなオブジェクトにする必要がないものはグローバル変数の使用を避ける(or 定数を使う)
var Message = "Hello, World!"
func ShowMessage() {
fmt.Println(Message)
}
func main() {
// デフォルトメッセージを表示
ShowMessage()
// メッセージ更新
Message = "Goodbye, World!"
// 更新後のメッセージを表示
ShowMessage()
}
ではなく
const DefaultMessage = "Hello, World!"
func ShowMessage(message string) {
fmt.Println(message)
}
func main() {
// デフォルトメッセージを表示
ShowMessage(DefaultMessage)
// 任意のメッセージを表示
ShowMessage("Goodbye, World!")
}
副作用のある処理は分離する
type ID string
func GenerateID() ID {
return ID(uuid.Make().Stirng())
}
ではなく
type MakeUUIDFunc = func() uuid.UUID
type ID string
func GenerateID(makeUUID MakeUUIDFunc) ID {
return ID(makeUUID().Stirng())
}
関数型プログラミングのテクニックを活用しよう
実は、Golangにおいても関数は第一級オブジェクトなので、関数型でできることの多くはGolangでもできます。
関数の合成
関数型プログラミングでは関数を値として扱い、「関数の実行結果を別の関数に渡したい」ようなケースでは、関数の合成で対応します。
Haskellでは以下のようにドットオペレータを使って関数を合成することができます。
-- 引数に1を加える関数
addOne :: Int -> Int
addOne x = x + 1
-- 引数の二乗を返す関数
square :: Int -> Int
square x = x * x
main :: IO ()
main = do
-- (x + 1) ^ 2 の計算
let result = (square . addOne) 3
putStrLn $ "Result: " ++ show result
-- Result: 16
Golangには関数合成演算子はないので、独自に実装して前述の例と同様のことをやってみます。
type Function func(int) int
// C 関数合成を合成するメソッド
// compose の C
func (f Function) C(g Function) Function {
return func(x int) int {
return f(g(x))
}
}
// AddOne 引数に1を加える関数
var AddOne Function = func(x int) int {
return x + 1
}
// Square 引数の二乗を返す関数
var Square Function = func(x int) int {
return x * x
}
func main() {
result := Square.
C(AddOne).
C(AddOne)(3)
fmt.Printf("Result: %d\n", result)
// Result: 25
}
このように関数を合成することで、関数を呼び出す度に状態を保持する変数を用意する必要がなくなり、入力に対して適用される関数が一対一になるため、とてもコードが読みやすくなります。
パイプ
関数合成では、処理の流れに対して関数の適用順は逆になるため、直感的にわかりづらいというデメリットがあります。
そのため、一部の関数型言語ではパイプ演算子 |>
というものが存在します。
これを使うと関数の適用を左から右(上から下)の向きに書くことができるため、直感的にわかりやすくなります。
Elixirの例
# 引数に1を加える関数
def add_one(x) do
x + 1
end
# 引数の二乗を返す関数
def square(x) do
x * x
end
result =
3
|> square()
|> add_one()
IO.puts("Result: #{result}")
# Result: 16
ではGolangでも再現してみます。
Golangではメソッドの機能を活用することで、同様のことができます。
type Int int
func (i Int) AddOne() Int {
return i + 1
}
func (i Int) Square() Int {
return i * i
}
func Example() {
result := Int(3).
AddOne().
Square()
fmt.Printf("Result: %d\n", result)
//Result: 16
}
※関数の合成ではなく都度関数を評価してしまっていますが、Golangに関数型言語の遅延評価システムは存在し無いため、合成と都度評価にパフォーマンス差はない(と思われ)ます。
(余談)Golang のメソッドはレシーバを第一引数にとる関数のシンタックスシュガー
メソッドを一度変数に突っ込んでみるとわかるように、Golang のメソッドはレシーバを第一引数にとる関数のただの別の書き方であることがわかります。
type MyInt int
func (myInt MyInt) Add(n int) MyInt {
return myInt + MyInt(n)
}
func main() {
addMyInt := (MyInt).Add
result := addMyInt(2, 3)
fmt.Println(result)
}
// Output:
// 5
つまりGolangのメソッド呼び出しに使用するドット.
は、関数型言語におけるパイプ演算子|>
とある意味同等のものであるとも考えられます。
論駁不可能性の保証
例えば条件によって変数の初期値を変えたいような場合、手続き的に書くとこうなります。
// 条件によって変数の初期値を変える場合
var grade string // 未初期化の変数を宣言
// 条件によって変数の状態を更新する
if score > 80 {
grade = "A"
} else if score > 60 {
grade = "B"
} else {
grade = "C"
}
このような実装のデメリットは、条件の網羅性(論駁不可能性)を人間が保証しなくてはならないことです。
以下の例では、score
がマイナスの時に grade
が未初期化の状態になってしまいます。
// 条件によって変数の初期値を変える場合
var grade string // 未初期化の変数を宣言
// 条件によって変数の状態を更新する
if score > 80 {
grade = "A"
} else if score > 60 {
grade = "B"
} else if score >= 0 {
grade = "C"
}
関数型では条件分岐も関数であるため、値の返却が保証されます。
grade = case score do
_ when score > 80 -> "A"
_ when score > 60 -> "B"
_ -> "C"
end
つまり、論駁可能性がある実装はそもそもコンパイルエラーになります。
grade = case score do
_ when score > 80 -> "A"
_ when score > 60 -> "B"
_ when score >= 0 -> "C"
end
Golangにもこのテクニックを取り入れることができます。
条件分岐を関数化することで、論駁不可能性をコンパイラによって保証することができるようになります。
grade := func() string {
if score > 80 {
return "A"
} else if score > 60 {
return "B"
} else {
return "C"
}
}()
以下の実装はコンパイルエラーになります。
grade := func() string {
if score > 80 {
return "A"
} else if score > 60 {
return "B"
} else if score >= 0 {
return "C"
}
}()
高階関数
高階関数とは、関数を引数にとる関数または、関数を返す関数のことを指します。
例えば、go1.22から試験的に追加された range over func はまさに高階関数を利用した仕組みです。
func rangeThree(yield func() bool) {
if !yield() {
return
}
if !yield() {
return
}
if !yield() {
return
}
}
func main() {
for range rangeThree {
fmt.Println("Hello")
}
}
// Output:
// Hello
// Hello
// Hello
DIの方法としても活用できます。
type GetUser func(db *sql.DB, id string) (User, error)
type Application struct {
db *sql.DB
// 抽象化された関数
getUser GetUser
}
func (a *Application) GetUser(id string) (User, error) {
user, err := a.getUser(a.db, id)
if err != nil {
return "", err
}
return user
}
Functional options pattern というデザインパターンもあります。
異なる型の可変長引数を使用したい場合に活用できます。
// デフォルト設定用の構造体
type Config struct {
Timeout int
MaxRetries int
Verbose bool
}
// オプション関数の型
type Option func(*Config)
// タイムアウトを設定するオプション関数
func WithTimeout(timeout int) Option {
return func(c *Config) {
c.Timeout = timeout
}
}
// 最大リトライ回数を設定するオプション関数
func WithMaxRetries(maxRetries int) Option {
return func(c *Config) {
c.MaxRetries = maxRetries
}
}
// ログ出力を有効にするオプション関数
func WithVerbose() Option {
return func(c *Config) {
c.Verbose = true
}
}
// 構造体を初期化する関数
func NewConfig(options ...Option) *Config {
config := &Config{
Timeout: 10, // デフォルトのタイムアウト
MaxRetries: 3, // デフォルトのリトライ回数
Verbose: false, // デフォルトのログ出力
}
// オプション関数を順に適用
for _, option := range options {
option(config)
}
return config
}
func main() {
// デフォルト値のConfig
config1 := NewConfig()
// 独自のタイムアウトを設定したConfig
config2 := NewConfig(WithTimeout(5))
// フルカスタム
config3 := NewConfig(
WithTimeout(5),
WithMaxRetries(1),
WithVerbose(true),
)
}
関数を返すタイプの高階関数は、関数のコンストラクタと捉えることができます。
つまり、関数を動的に生成することができるようになります。
また、別の見方をすると複数ある関数の引数を部分的に適用している、とも捉えられます。
func generateMultiplier(factor int) func(int) int {
// 引数として受け取ったfactorを使って、新しい関数を作成する
return func(x int) int {
return x * factor
}
}
func main() {
// 2倍する関数を生成
multiplyBy2 := generateMultiplier(2)
// 3倍する関数を生成
multiplyBy3 := generateMultiplier(3)
// それぞれの関数を使って計算する
result1 := multiplyBy2(5)
result2 := multiplyBy3(5)
fmt.Println("Result1:", result1)
fmt.Println("Result2:", result2)
// Result1: 10
// Result1: 15
}
HTTPハンドラに認証機能を追加するミドルウェアの実装等にも活用できます。
func authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if isAuthorized(r) {
next.ServeHTTP(w, r)
} else {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
}
}
}
func main() {
http.HandleFunc("/open", openHandler)
http.HandleFunc("/protected", authMiddleware(protectedHandler))
http.ListenAndServe(":8080", nil)
}