はじめに
こんにちは、本日のディップ株式会社 Advent Calendar 2023の記事です。
普段はデータサイエンティストの仕事をしていまして、主にRとSQLなどを利用しています。
今回の記事では、エンジニア向けに、Rの中で複数存在しているオブジェクト指向プログラミング(OOP)システムの中で、一番幅広く利用されて、歴史が古く、かつ単純なS3について実践的に説明します。
データ分析の話は一切出てきませんのでご安心ください。
前提
詳細はこの記事を読んでいただきたいですが、
Rには複数のOOPシステムがあります。中でもS3、S4、R6が有名です。S7はまだテスト段階で、今後S3とS4の正式な後継システムになる予定です。
まず、S3は確かに自由すぎて怖いです。恐ろしいことはやり放題です。ただ、S3はかなり歴史があるため、変な挙動とか、S3でやるべきではないことは大体整理されて、ググったりChatGPTに聞いたりすれば良質な回答が得られます。
また、実例を挙げますと、Rの有名なggplot2パッケージもS3で書かれたので、S3を落ち着いて理解して、しっかりRのコードを書けば、S3は複雑なシステムでも利用できます。
逆に、上の記事で紹介したRの最新のOOPシステムS7は確かにデザイン理念こそ素晴らしいですが、挙動が理解され尽くしていないので自分としてはS3より怖いです。
この記事では、安全なS3の利用方法とよろしくないS3の利用方法を両方紹介しますので、ぜひこれをきっかけにRの理解を深めてみてください!
お買い物クラス
前回のS7の記事と同様、お買い物クラスの要件は
-
商品名と値段を記録したい
-
商品名ベクトルと値段ベクトルの長さは一致しないといけない
-
商品の値段はゼロ以上でないといけない
の三つです。
Pythonユーザーのため、本記事で定義したいpurchase_recordのPython版も作ってみました。私のPython知識は本当に貧弱なので、もし何かミスがあったら許してください(土下座)。やりたいことの気持ちだけ理解していただければ幸いです。
class Purchase_record:
def __init__(self, item_name, item_price):
if not all(isinstance(name, str) for name in item_name):
raise ValueError("item_nameはstr型でないといけません")
elif not all(isinstance(price, (int, float)) for price in item_price):
raise ValueError("item_priceはint型かfloat型でないといけません")
elif len(item_name) != len(item_price):
raise ValueError(
f"item_nameの長さ({len(item_name)})とitem_priceの長さ({len(item_price)})が一致していません。"
)
elif any(price < 0 for price in item_price):
negative_price_items = [name for name, price in zip(item_name, item_price) if price < 0]
raise ValueError(
f"item_priceはゼロ以上でないといけません。あなたは{', '.join(negative_price_items)}にゼロ以下のitem_priceを設定しています。"
)
else:
self.item_name = item_name
self.item_price = item_price
def __add__(self, another_purchase_record):
if isinstance(another_purchase_record, Purchase_record):
new_item_name = self.item_name + another_purchase_record.item_name
new_item_price = self.item_price + another_purchase_record.item_price
return Purchase_record(new_item_name, new_item_price)
else:
raise TypeError("Purchase_record型ではありません")
def summary(self):
sum_name = 'と'.join(self.item_name)
sum_price = sum(self.item_price)
print("あなたは" + sum_name + "を買いました。値段は" + str(sum_price) + "円です。")
このように、PythonはPurchase_recordクラスが自身のメソッド(__add__
とsummary
)を持ちますが、RのS3、S4、S7の場合、メソッドは総称関数が持つことになりますので気をつけましょう。
おふざけ
さて、S7の記事と同じような機能を持つS3版のお買い物クラスを定義しましょう。
> buy_formula <- ~ a + b + c
> class(buy_formula) <- "purchase_record_s3"
> buy_formula
~a + b + c
attr(,"class")
[1] "purchase_record_s3"
attr(,".Environment")
<environment: R_GlobalEnv>
できました笑。class<-関数で無理やり回帰分析でお馴染みのformulaをまだ定義もされていないpurchase_record_s3に強制変換できましたー
S3クラスのインスタンスの正体(?)はclassというattributeを持つリストです。
バリデーションをしろとツッコミたい読者もいるかもしれませんが、まず定義されていないクラスのインスタンス作成を許容すんな!のツッコミの方が優先度高いですね。
はい、ここからは真面目に前回の記事のpurchase_recordのS3版の作り方を説明します。
方法を三つ紹介します。方法が三つ(以上)あるのも怖いですね、、、
1、listにclassを指定する術
まず、こんな感じでリストを作成してからclass<-関数を利用してクラスを指定する方法があります。
> buy_apple <- list(
item_name = "りんご",
item_price = 100
)
> class(buy_apple) <- "purchase_record_s3"
> buy_apple
$item_name
[1] "りんご"
$item_price
[1] 100
attr(,"class")
[1] "purchase_record_s3"
これで外見上前回の記事で紹介したS7版のpurchase_recordのようになりました。
2、structure関数の術
次に、structure関数で作成する方法もあります。
> buy_juice <- structure(
list(
item_name = "ジュース",
item_price = 100
),
class = "purchase_record_s3"
)
> buy_juice
$item_name
[1] "ジュース"
$item_price
[1] 100
attr(,"class")
[1] "purchase_record_s3"
3、インスタンス作成関数を自分で定義する術(推奨)
見てわかると思いますが、前述のlistにclassを指定する術とstructure関数の術の場合、作成されたインスタンスのデータがクラスの要件を満たしているかを確認できません。
そこで、自分でインスタンス作成用の関数を用意して、すべての要件を満たした入力に限って、インスタンス作成を成功させる方法を推奨します。
具体的にはこんな感じです:
new_purchase_class_s3 <- function(item_name, item_price){
if (class(item_name) != "character"){
rlang::abort(message = "item_nameにはcharacter型を指定してください。")
}else if (class(item_price) != "numeric"){
rlang::abort(message = "item_priceにはnumeric型を指定してください。")
}else if (length(item_name) != length(item_price)){
stringr::str_c(
"item_nameとitem_priceの長さは一致しないといけません。",
"item_nameの長さが", length(item_name), "になっているのに対して",
"item_priceの長さが", length(item_price), "です。"
) |>
rlang::abort(message = _)
}else if (any(item_price < 0)){
negative_price_item <- item_name[which(item_price < 0)]
stringr::str_c(
"item_priceは0以上でないといけません。あなたは",
stringr::str_c(negative_price_item, collapse = "と"),
"に0以下のitem_priceを設定しました。"
) |>
rlang::abort(message = _)
}else{
structure(
list(
item_name = item_name,
item_price = item_price
),
class = "purchase_record_s3"
)
}
}
Python版のPurchase_recordの__init__
関数のような見た目ですね。
ではまずは使ってみましょう!
正しいインスタンス作成
一商品でも
> new_purchase_class_s3(item_name = "りんご", item_price = 100)
$item_name
[1] "りんご"
$item_price
[1] 100
attr(,"class")
[1] "purchase_record_s3"
複数商品でも
> new_purchase_class_s3(item_name = c("りんご", "みかん"), item_price = c(100, 150))
$item_name
[1] "りんご" "みかん"
$item_price
[1] 100 150
attr(,"class")
[1] "purchase_record_s3"
問題なさそうですね。
正しくないインスタンス作成の試み
では変な入力を渡してみましょう。
まずはitem_nameにformulaを渡します。
> new_purchase_class_s3(item_name = ~ a + b + c, item_price = 100)
Error in `new_purchase_class_s3()`:
! item_nameにはcharacter型を指定してください。
Run `rlang::last_trace()` to see where the error occurred.
次はitem_nameとitem_priceの長さが異なる入力です。
> new_purchase_class_s3(item_name = c("りんご", "みかん"), item_price = 100)
Error in `new_purchase_class_s3()`:
! item_nameとitem_priceの長さは一致しないといけません。item_nameの長さが2になっているのに対してitem_priceの長さが1です。
Run `rlang::last_trace()` to see where the error occurred.
最後はマイナス価格です
> new_purchase_class_s3(item_name = c("りんご", "みかん"), item_price = c(100, -150))
Error in `new_purchase_class_s3()`:
! item_priceは0以上でないといけません。あなたはみかんに0以下のitem_priceを設定しました。
Run `rlang::last_trace()` to see where the error occurred.
問題なさそうですね!
S7との比較
S7の良さの話になりますが、S7の場合、S7::new_class関数のpropertiesがデータの型を、validatorが型以外の要件(長さが一致しているかなど)で引数間の役割分担をしているのに対して、S3の自作インスタンス作成関数の場合、全部if関数になるので、今のif関数がどんなことをチェックしているかは自明ではありません。これはPythonのデフォルトのクラス定義の__init__
関数にも共通する問題だと思います。(Pythonの場合はif関数ではなくif文ですね)
なので、開発者が可読性と保守しやすさを意識してしっかり関数を定義するしかないです。
お買い物クラスのメソッドを総称関数に登録する
次は、purchase_record_s3にメソッドを定義しましょう。二つの総称関数にメソッドを登録します。
概念
S3クラスのメソッドを総称関数に登録する方法もなかなか怖いです。
総称関数名.クラス名 <- function(xxx){
実装
}
これでいけます笑。
詳細は別の記事で説明しないといけないですが、概念上、総称関数が入力を受け取ったら、入力されたデータの型と自身の関数名で「総称関数名.クラス名」の文字列を生成して、.__S3MethodsTable__.
という内部のnamespaceで実装を探します。例えばggplot2の+演算子(関数)の実装もここに隠されています
> .__S3MethodsTable__.$`+.gg`
function (e1, e2)
{
if (missing(e2)) {
abort("Cannot use `+.gg()` with a single argument. Did you accidentally put + on a new line?")
}
e2name <- deparse(substitute(e2))
if (is.theme(e1))
add_theme(e1, e2, e2name)
else if (is.ggplot(e1))
add_ggplot(e1, e2, e2name)
else if (is.ggproto(e1)) {
abort("Cannot add ggproto objects together. Did you forget to add this object to a ggplot object?")
}
}
<bytecode: 0x5104bfda8>
<environment: namespace:ggplot2>
ただ、.がすべて総称関数が持つメソッドを意味するわけではないです。例えばdata.frame関数は別にframe型に対するdata総称関数の実装を意味しません。
このような書き方の意味の不一致はRの発展の足跡です。
Rは統計分析ツールとして誕生したため、まずはデータの置き場のdata.frameが誕生してから、そういえばOOPもあった方が便利だねってなってS3が誕生しました。
ただ、コードの後方互換性は統計分析にとって非常に重要なため、S3が誕生したからといってdata.frameをdata_frameに変えたりしませんでした。
ちなみに、親クラスと子クラスがある場合、まずは子クラスで「総称関数名.クラス名」を生成し、.__S3MethodsTable__.
に実装が登録されていなければその子クラスの親クラスで「総称関数名.クラス名」を生成する感じです。なお、S3には多重継承の概念がありません。詳細は割愛します。
+
具体的なメソッド登録を確認しましょう。
まずは+関数です。
`+.purchase_record_s3` <- function(e1, e2){
combined_name <- vctrs::vec_c(e1$item_name, e2$item_name, .ptype = character())
combined_price <- vctrs::vec_c(e1$item_price, e2$item_price, .ptype = numeric())
return(new_purchase_class_s3(combined_name, combined_price))
}
実装内容はS7の記事とほぼ同じですね。
動作を確認しますと、
> buy_apple <- new_purchase_class_s3(item_name = "りんご", item_price = 100)
> buy_juice <- new_purchase_class_s3(item_name = "ジュース", item_price = 150)
> buy_apple + buy_juice
$item_name
[1] "りんご" "ジュース"
$item_price
[1] 100 150
attr(,"class")
[1] "purchase_record_s3"
summary
次に、総称関数summaryにpurchase_record_s3のメソッドを登録しましょう:
summary.purchase_record_s3 <- function(object){
stringr::str_c(
"あなたは",
stringr::str_c(object$item_name, collapse = "と"),
"を買いました。合計金額は",
sum(object$item_price),
"円です。"
)
}
最後に、動作を確認しましょう:
> summary(buy_apple + buy_juice)
[1] "あなたはりんごとジュースを買いました。合計金額は250円です。"
問題ないですね!
結論
Rで最も広く使われているOOPシステムS3の紹介でした。いかがでしたか?
まとめますと
- S3クラスを定義するときはインスタンス作成関数を作成して、型の正確性を担保しましょう
- S3クラスのメソッドは総称関数が持ち、開発者が総称関数にクラスのメソッドを登録する
S3は確かに諸々自由すぎて怖いですが、逆にいうと制限があまりなく単純です。
今回の記事では時間の問題で新しい総称関数を定義する方法を紹介できなかったんですが、個人的には総称関数の命名がS3の一番厄介なところです。
これは本当に滅多に起きないことで、Rを勉強し始めた六年前から一回しか経験したことないですが、パッケージが定義した総称関数と別のパッケージが定義した総称関数の名称が一緒の場合、先にlibraryで読み込まれたパッケージの総称関数が消えて、::で呼び出さないといけなくなります。
今後はまた別の記事でS3で新しい総称関数を定義する方法と、私が計量マクロ経済学で直面した上記の現象とその解決策を説明したいと思います。
また、総称関数の上書き問題はどうして滅多に起きないのかも説明しますので、お楽しみしていただければと思います!