結論: 「副作用」の概念を知り、変更の影響を局所化できるような書き方を身に着けましょう
R の関数の動作に関して、あるご質問を頂きました。 他の方にとっても有用な議論と思われたので、記事にすることにしました。
はじめに 🔗
R 言語はその設計において「関数型プログラミング」の原則を採用しています。 R を使ったプログラミングでは、関数が返却する値「返り値」をもとにプログラムを組み立てるのがセオリーです。
この時点で既に説明が難しいかもしれませんので、実例とともに解説します。
関数は通常、それが操作するデータのオリジナルのコピーを作成し、それを操作します。この記事では、R 言語の関数と、それが持つ可能性のある「副作用」について考察します。
R 言語の関数の特性 🔗
gsub()
という、文字列置換に使う関数を使って、例を見てみましょう。
target <- "world!"
print(paste("Hello,", target))
print(paste("Hi,", gsub("world", "R", target)))
print(paste("Goodbye,", target))
これを実行すると、下記のように表示されます:
Hello, world!
Hello, R!
Goodbye, world!
3 行目に( Goodbye, R!
ではなく) Goodbye, world!
と出力されるところがポイントです。
あれっ、 "world"
を "R"
に置換したのに?
これはなぜでしょうか?
これは R の関数が 副作用 を持たないため、つまり gsub()
が target
変数のオリジナルの値を変更しないためです。
gsub()
は呼び出されると、置換処理を始める前に、受け取った文字列(この場合は "world"
)のコピーを作成し、そのコピーに対して置換処理を実行します。
このような動作のおかげで、先程のケースでは元の変数 target
の値は変更されず "world"
のままだったのです。
この挙動にどんな印象を持たれるでしょうか? 意外?直感的でない?面倒?
あなたが関数型言語にどのぐらい親しんでいるかによって反応は違うでしょうが、とにかく R は関数型言語なのでこの挙動に慣れる必要があります。 慣れてください。
関数に対する理解を深めるためには、対照的な挙動、つまり副作用を持ったプログラムについて実例を見てみるのがいいかもしれません。
副作用とは? 🔗
副作用は、関数がシステムの状態を変更する、または関数外の何かと相互作用するときに起こります。 つまり、関数がグローバル変数の値を変更したり、データベースに書き込んだり、画面に何かを描画したりするときなどです。
勘のするどい方は、R でも副作用のある関数1があることに気づかれるかもしれませんが、そのような関数2は関数全体からするとごく一部です。 副作用はシステム開発に不可欠ではありますが、副作用を利用しはじめるとプログラムはどうしても複雑になり、バグが紛れ込みやすくなります。 R が関数プログラミングに基礎を置いている理由はほぼ明白で、システム開発ではなくデータ解析をするために生まれた言語であるためでしょう。 つまり、副作用によってもたらされるメリットよりも、デメリットの方が大きくなることが予想されるためです。
先ほど、 gsub()
による文字列置換の例を示しましたが、もし gsub()
が副作用を持っていたとしたら、何が起こるでしょうか?
副作用のある文字列置換関数 gsub_danger()
を定義して実験してみましょう:
gsub_danger <- function(pattern, replacement, x) {
# substituteで変数名を取得、deparseで文字列化
var_name <- deparse(substitute(x))
# もし x がグローバル環境に存在する変数ならばその値を、そうでないならば x 自体を利用
if (exists(var_name, envir = .GlobalEnv)) {
# gsub()を使用して文字列を更新
result <- gsub(pattern, replacement, get(var_name, envir = .GlobalEnv))
# グローバル環境の変数の値を更新。つまりここに副作用がある
assign(var_name, result, envir = .GlobalEnv)
} else {
# gsub()を使用して文字列を更新。つまりオブジェクトでなく文字列がリテラルで渡された場合には gsub() と同じ挙動
result <- gsub(pattern, replacement, x)
}
return(result)
}
定義がやや複雑ですが、これは関数の中で無理に副作用を起こさせようとしているためです。 一般的な R プログラミングでは使わない書き方ですので、読み飛ばしてくださって構いません。
gsub_danger()
を使って、先程の置換をやってみます:
target <- "world!"
print(paste("Hello,", target))
print(paste("Hi,", gsub_danger("world", "R", target)))
print(paste("Goodbye,", target))
結果はこうです
Hello, world!
Hello, R!
Goodbye, R!
gsub_danger()
には副作用がありますので、一度呼び出しただけで、最後の print()
の出力にも影響しています。
これくらいなら困らないとは思いますが、もし変数 target
が他にも何か重要な計算に利用されていたらどうなるでしょうか?
target <- "world!"
print(paste("Hello,", target))
print(paste("Hi,", gsub_danger("world", "R", target)))
print(paste("Goodbye,", target))
result <- important_calculation(target)
これでは、 gsub_danger()
によって、重要な計算の結果が意図せず変わってしまいますね。
ということで、副作用を咥えて生まれてきたような言語ならいざ知らず、少なくとも R を利用する場合には、副作用を利用したプログラミングは避けるべきです。
もっとも、上で gsub_danger()
を定義したように、R は追加のコストを支払わない限り副作用を利用できない言語ですので心配は要りません。
ただし副作用を利用しなくとも、ある場所の変更が離れた場所にも影響するような書き方はできてしまいますので、そこは注意が必要です。
変更の影響を局所化するには 🔗
先程の文字列置換の例で、副作用があるときのような挙動を意図していた場合にはどうしたらいいでしょうか?
つまり、副作用のない関数 gsub()
を使いながらも
Hello, world!
Hello, R!
Goodbye, R!
と出力したい場合です。
答えは簡単で、単に下記のように書くだけです。
target <- "world!"
print(paste("Hello,", target))
print(paste("Hi,", gsub("world", "R", target)))
print(paste("Goodbye,", gsub("world", "R", target)) # ここでも gsub() の返り値を利用
最後の print()
でも、愚直に gsub()
を利用しています。
これが、「関数の返り値を利用してプログラムする」ということです。
では、 "なんとかかんとか, R!"
と表示したい箇所があと 100 行あったらどうでしょうか?
その場合には、少なくとも下のように書けるでしょう3:
target <- "world!"
replaced <- gsub("world", "R", target) # 新しい変数に代入しておく
print(paste("Hello,", target))
print(paste("Hi,", replaced))
print(paste("Goodbye,", replaced)
# 以下略...
ただしここで、避けるべき(しかしよく見る)悪手が一つあります:
target <- "world!"
print(paste("Hello,", target))
target <- gsub("world", "R", target) # 悪手
print(paste("Hi,", target))
print(paste("Goodbye,", target)
# 以下略...
変数 target
への再代入です。
これをやってしまうと、せっかく gsub()
が副作用のない実装になっているのに、 gsub_danger()
を利用した場合と変わらなくなってしまいます。
意図しないバグを生む可能性がありますのでお気をつけて。
ChatGPT に聞いてみた 🔗
理解の助けとなるかわかりませんが、例え話での説明を検討していたときに ChatGPT に相談したときの会話を貼っておきます
あなた: 純粋関数と、副作用を持つ関数との違いを、例え話で説明しようと思っています。現在の案は下記の通りですが、もっとよい例えがあったら教えてください
- 純粋関数: 電子レンジに食品を入れてボタンを押す。処理が終わったら必ず中身を取り出し、それを利用する(この例は、食品が温まってしまうところが副作用を想起させてしまう恐れがありますので改善したいです)
- 副作用のある関数: ボタンを押すと、どこか離れた場所でロケットが発射される
ChatGPT: 確かに、電子レンジの例では、食品が加熱されることが「副作用」を想起させるかもしれません。これをより明確にするために、以下に新しいたとえを提案します:
- 純粋関数:「自動販売機」を考えてみましょう。自動販売機にコインを入れてボタンを押すと、選んだ商品が出てきます。ここで重要なのは、同じコインとボタンを使えば、何度でも同じ商品が出てくるということです。自動販売機(関数)は外部の状況に影響されず、同じ入力(コインとボタン)に対しては常に同じ出力(商品)を提供します
- 副作用:一方、副作用の例としては、「リモートコントロールのボタン」を考えてみましょう。テレビのリモートコントロールのボタンを押すと、テレビが切り替わります。しかし、それはリモートコントロール自体に影響を与えず、リモートコントロールの外部、つまりテレビの状態を変更します。これは「副作用」です。リモートコントロール(関数)のボタンを押す(関数を実行する)と、その結果としてテレビの状態(グローバル変数)が変更されます
あなた: テレビは現在の「チャンネル」「ボリューム」という状態を持っているので、同じボタンを押したときの結果は、そのときどきのテレビの状態によって違うということですね
ChatGPT: はい、その通りです。リモートコントロールの「チャンネル切り替え」ボタンや「ボリューム調節」ボタンを押すと、テレビの状態(現在のチャンネルやボリューム)は変わります。 そして、その状態はボタンを押すたびに変化します。したがって、同じボタンを押したとしても、その結果(つまりテレビの新しいチャンネルやボリューム)は前回と異なることがあります。 これが「副作用」の一例です。つまり、関数(この場合はボタンを押す行為)がシステムの全体的な状態(この場合はテレビの状態)に影響を与え、その結果が前回と異なる出力を生む可能性があるということです。
あなた: 自販機は、「商品の数」という状態をもつとも考えられるので、もっといい例えはないですか?
ChatGPT: その通りで、自動販売機の例では商品の在庫という外部状態が関与してきますね。より適切な例を考えてみましょう: 数学の計算を考えてみてください。たとえば「2に3を加える」計算です。この計算は純粋な関数と考えることができます。2と3を引数として与えると、常に同じ結果、つまり5を返します。この計算を何度実行しても、何があろうとも常に同じ結果が得られます。計算自体は何も変えず、与えられた数値だけを使って結果を出します。
けっきょく数学の話になって笑ってしまいました。でも、副作用がないとはそういうことです。