Introduction
関数という概念はGo言語でも馴染みがあるし、普通のプログラミングと何が違うの?と感じる人は少なくないと思います。
でも、実はGo言語のような所謂「手続型プログラミング」と「関数型プログラミング」は全く異なるパラダイムです。
関数型プログラミングを理解すると、プログラミングを別の角度から見ることができるようになり、視野がグッと広がります。
今日は、Gopherの皆さんに、関数型プログラミングの世界について紹介します。
※この記事では計算理論や圏論などの領域には触れません。また、わかりやすさを優先し、厳密ではない言葉の使い方をしている箇所がありますが、ご容赦ください。
メンタリズムの違い
Go言語は所謂、手続き型言語に該当します。
手続き型言語の元祖であるC言語は、アセンブリに変わるシステムプログラミングのための高水準言語として生まれました。
つまるところ、手続き型言語においてプログラムとは「コンピュータを制御するためのもの」というメンタリズムなわけです。
一方で、関数型パラダイムはラムダ計算を基盤とし、代数学や集合論の影響を受けて発展しました。
そのため、関数型言語では「プログラムとは計算可能な関数の集合である」というメンタリズムを持っています。
関数型プログラミングのメリット
- テスタビリティの向上
- 保守性の向上
- 堅牢性の向上
Go言語の関数は”関数”ではない!
ところで、皆さんが最初に知った「関数」という概念はどのようなものだったでしょうか?
恐らく多くの人は、初等数学で習った f(x)=2x
のようなヤツだと思います。
関数には何か計算式が定義されていて、入力を受け取って値を返却する、というものですね。
例えば以下の関数は、入力された数値を二乗して返却する関数です。
f(x) = x^2
関数の基本はこれです。
Go言語では、概念の類似性から便宜上「関数」と表現されているだけで、本来の「関数」ではありません。
なぜならば、Goの関数は呼び出しても何も結果が返ってこなかったり、はたまた突然 panic を起こしてしまったりもします。
ここでいう本当の意味での関数を関数として取り扱うのが関数型言語であり、関数型プログラミングです。
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;
}
}
これはテストの可用性が低下するだけでなく、再現性や検証を難しくする大きな要因になります。
一方で、副作用が無い関数は同じ入力に対して必ず同じ出力を返すため、常に予測可能な振る舞いを示します。
入力と出力の間に明確な対応があるため、テストの可用性と、信頼性が向上します。
特に、副作用がない関数のことを「純粋関数」と呼んだりします。
Go言語でも副作用の少ない関数を書こう
レシーバを直接変更するタイプのメソッドを避ける
type User struct{ Name string }
func (user *User) ChangeName (name string) {
user.Name = name
}
ではなく
type User struct{ Name string }
func (user User) ChangeName (name string) User {
user.Name = name
return user
}
シングルトンなオブジェクトにする必要がないものはグローバル変数の使用を避ける(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 Record struct {
ID int
CreatedAt time.Time
}
func NewRecord(id int) *Record {
return &Record{
ID: id,
CreatedAt: time.Now(),
}
}
record := NewRecord(1)
ではなく
type Record struct {
ID int
CreatedAt time.Time
}
func NewRecord(id int, timeFunc func() time.Time) *Record {
return &Record{
ID: id,
CreatedAt: timeFunc(),
}
}
record := NewRecord(1, time.Now)
関数型プログラミングのテクニックを活用しよう
実は、Go言語においても関数は第一級オブジェクトなので、関数型でできることの多くはGo言語でもできます。
関数の合成
関数型プログラミングでは関数を値として扱い、「関数の実行結果を別の関数に渡したい」ようなケースでは、関数の合成で対応します。
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
Go言語には関数合成演算子はないので、独自に実装して前述の例と同様のことをやってみます。
type Func[T any] func(T) T
// C 関数合成を合成するメソッド(compose の C)
func (f Func[T]) C(g Func[T]) Func[T] {
return func(x T) T {
return f(g(x))
}
}
// AddOne 引数に1を加える関数
func AddOne(x int) int {
return x + 1
}
// Square 引数の二乗を返す関数
func Square(x int) int {
return x * x
}
func main() {
result := Func[int](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
ではGo言語でも再現してみます。
Go言語ではメソッドの機能を活用することで、同様のことができます。
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
}
(余談)Go言語 のメソッドはレシーバを第一引数にとる関数にダウンキャスト可能
メソッドを一度変数に突っ込んでみるとわかるように、Go言語 のメソッドはレシーバを第一引数にとるただの関数に変換ができます。
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
つまり、Go言語のメソッド呼び出しに使用するドット.
は関数型言語におけるパイプ演算子|>
と近いものであると考えられます。
論駁不可能性の保証
例えば、条件によって変数の初期値を変えたいような場合、手続き的に書くとこうなります。
// 条件によって変数の初期値を変える場合
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
Go言語には網羅性をチェックする機能がありませんが、代わりに else 句(switchならばdefault句)の保証を行うことは可能です。
条件分岐を関数化することで、実現できます。
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)
}