はじめに
こんにちは、事業会社で働いているデータサイエンティストです。
普段の業務ではR(とSQL)を使っていて、Rの最先端も確認していますので、今回の記事では最近CRANでリリースされた新しいオブジェクト指向システムS7について色々話そうと思います。
公式サイトはこちらです:
注意事項
公式サイトでも強調されていますが、S7はまだかなり早期の試みであって、パッケージとしては安定していません。なので、読者が本記事を数ヶ月後に確認する時、すでに一部の機能に重大な変更が入ってコードが実行できない可能性があります。
また、まだテスト段階のパッケージなので、個人的には何かしらの商用サービスに組み込むことは避けた方がいいと思います。
S7とは
R言語はもともと計量経済学者、統計学者、計量政治学者、生物学者、医学者が利用する統計ソフトとして誕生しました。統計分析において、再現性は極めて重要なので、Rのコミュニティには後方互換性を重視す部分かがあり、過去に実装されたよろしくないシステムや関数を修正(?)しない傾向があります。
R言語はもともとS3とS4という、クラスはデータを持ち、メソッドは総称関数が持つオブジェクト指向システムと、Pythonのように、クラスが同時にデータとメソッドを持つR6システムがあります。その中で、S3とS4の利用が一番一般的です。個人的にもS3のようにクラスと総称関数を別にして管理する方式が好きです。
(この三つのシステムの中で特に重要なS3については、また今後記事を書いて紹介します)
ちなみに、S3とS4の「S」はR言語の前身のS言語が由来です。
このように、後方互換性重視するこだわりによって、複数のオブジェクト指向システムが共存(というか群雄割拠?)している状況になり、R言語のキープレイヤーたちを集めて、最新でみんなが納得できるオブジェクト指向システムを作りましょうというのがS7プロジェクトの発端です。
S7はS3とS4のように、クラスはデータを持ち、メソッドは総称関数が持つオブジェクト指向システムです。また、R言語自身、Python、Juliaなどの他の言語の教訓も参考にしながら開発されているので、厳密にバリデーションをかけられるなどのモダンなデザインとなっています。
どうしてS7?
S7を使う理由としては主に二つあります。
まず、インスタンス作成時に厳密なバリデーションがかけられるので、複雑なシステムを作るときのバグを回避できます。また、この後お見せしますが、S7のクラス定義には最初からバリデーション用の引数が用意されているので、Pythonのように自分で__init__の中でバリデーション用の処理を用意する必要がないため、可読性と保守しやすさが向上すると思います。
二つ目は若干社会的なことなんですが、S7は長期的にはbase Rの組み込まれる予定なので、今の段階で重大な設計ミスなどを発見してS7のgithubレポジトリで報告して、さらに解決方法まで提案すれば、R言語のコミュニティ内の知名度はかなり上がると思います。
S7を使ってみる
では、ここでは早速S7を使ってみましょう!Rは統計ソフトと勘違いする人がかなり多いので、以下の事例ではあえてデータ分析とは全く関係のない、純粋なソフトウェア開発にありそうなタスクでS7を説明します。
クラスの要件
さて、お買い物クラスを定義しましょう。
定義する前に、このクラスに何を求めるのかをまず考えましょう。
繰り返しになりますが、S7はPythonとは違って、メソッドはクラスではなく総称関数が持つので、今はデータの方に集中します。
まずは、お買い物クラスなので、
商品名と値段を記録したい
次に、商品名と値段の長さが違うのはミスに違いないので、
商品名ベクトルと値段ベクトルの長さは一致しないといけない
最後に、マイナスの値段は許可したくないので、
商品の値段はゼロ以上でないといけない
クラスの定義
ここで、上記の要件に基づいてクラスを定義しましょう。
まずコードはこんな感じです:
library(S7)
# 必要ないですが可読性のためS7::は書いておきます
purchase_record <- S7::new_class(
name = "purchase_record",
properties = list(
item_name = S7::class_character,
item_price = S7::class_numeric
),
validator = function(self){
if (length(self@item_name) != length(self@item_price)){
stringr::str_c(
"@item_nameと@item_priceの長さは一致しないといけません。",
"@item_nameの長さが", length(self@item_name), "になっているのに対して",
"@item_priceの長さが", length(self@item_price), "です。"
)
}else if (any(self@item_price < 0)){
stringr::str_c(
"@item_priceは0以上でないといけません。あなたは",
self@item_price,
"を設定しました。"
)
}else{
NULL
}
}
)
purchase_recordにpurchase_recordクラスを代入するような感じで少し違和感感じる人もいるかもしれませんが、ここでは我慢しましょう。
S7::new_classで、nameの引数でクラス名を指定します。ここではpurchase_record、購買記録ですね。
次に、propertiesの引数でlist型でインスタンス作成時のデータの型を指定します。ここではすでにバリデーションを厳格にかける傾向が見えていますが、さらに下のvalidatorの引数で、バリデーション方法を指定できます。
validatorに渡す関数は、selfを引数にします。selfはPythonのクラス作成の__init__の第一引数によく使われるselfと似ていて、暫定的に内部で作ってみたインスタンスです。validatorに渡された関数がNULLを返せば、インスタンスの作成が正式に成功しますが、character型を返したら作成が失敗し、返されたcharacterがエラーメッセージと共に表示されます。
「@」はPythonのクラス作成の時に「.」に近く、インスタンスのデータを取り出す演算子です。
インスタンス作成
では、早速インスタンスを作ってみましょう:
> purchase_record("りんご", 100)
<purchase_record>
@ item_name : chr "りんご"
@ item_price: num 100
はい、100円のりんごです。
マイナス100円のりんごも欲しいなー、でも:
> purchase_record("りんご", -100)
Error: <purchase_record> object is invalid:
- @item_priceは0以上でないといけません。あなたは-100を設定しました。
正しく弾かれました。こんな感じでvalidatorの関数が返すcharacterが現れます。
長さが違う場合でも:
> purchase_record("りんご", c(10,20))
Error: <purchase_record> object is invalid:
- @item_nameと@item_priceの長さは一致しないといけません。@item_nameの長さが1になっているのに対して@item_priceの長さが2です。
このように、S7は厳密にバリデーションをかけながら、開発者に有益なエラーメッセージを提供することで、デバッグの難易度を下げます。
総称関数へのメソッドの登録
次は、purchase_recordにメソッドを定義しましょう。
+
まずは二つのpurchase_recordをくっつけるために、総称関数「+」に{purchase_record, purchase_record}に対するメソッドを新規登録します。
S7::method(`+`, list(purchase_record, purchase_record)) <- 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(purchase_record(combined_name, combined_price))
}
S7は、S7::method(総称関数名, クラス名(クラス名のリスト)) <- function()の書き方でメソッドを実装します。
ここで定義されるメソッドの引数名がe1とe2になっているのは総称関数「+」の指定です。
やっていることは単純で、「+」関数が受け取った二つのpurchase_recordインスタンスのitem_nameとitem_priceをそれぞれ取り出してくっつけて新しいインスタンスを作る処理です。
vctrs::vec_cはbase::cのバリデーション強化版で、こっそりデータを変更したりするのではなく、エラーを吐きます。
> # base::cは1と2をcharacter型に強制変換する
> c(1,2,"a")
[1] "1" "2" "a"
> # vctrs::vec_cはエラーを吐く
> vctrs::vec_c(1,2,"a")
Error in `vctrs::vec_c()`:
! Can't combine `..1` <double> and `..3` <character>.
Run `rlang::last_trace()` to see where the error occurred.
さらに、vctrs::vec_cは格納するデータの型まで指定できます。
> # データがintegerかdoubleかは問わない
> vctrs::vec_c(1.2, 1.3, 1.9, .ptype = numeric())
[1] 1.2 1.3 1.9
> # データがintegerになるように指定
> vctrs::vec_c(1.2, 1.3, 1.9, .ptype = integer())
Error in `vctrs::vec_c()`:
! Can't convert from `..1` <double> to <integer> due to loss of precision.
• Locations: 1
Run `rlang::last_trace()` to see where the error occurred.
クラス定義のところでバリデーションしたのにやりすぎてませんかと言われるかもしれませんが、想定外の動作のリスクより、バリデーションを大人しくかけたほうが、デバッグ工数が減ります。
余談になってしまいましたが、早速「+」メソッドを使いましょう:
> buy_apple <- purchase_record("りんご", 100)
> buy_juice <- purchase_record("ジューズ", 150)
> buy_apple
<purchase_record>
@ item_name : chr "りんご"
@ item_price: num 100
> buy_juice
<purchase_record>
@ item_name : chr "ジューズ"
@ item_price: num 150
> buy_apple + buy_juice
<purchase_record>
@ item_name : chr [1:2] "りんご" "ジューズ"
@ item_price: num [1:2] 100 150
ちなみに、「+」という総称関数に対するメソッドの定義の新規登録なので、「+」の本来のメソッドは正常に動作します:
> 1 + 1
[1] 2
ggplot2も似たような方法で、ggplot(data) + geom_point(aes(x = x, y = y))のような記法を実現しています。
実装方法を確認する際はこんな感じでできます:
> S7::method(`+`, list(purchase_record, purchase_record))
<S7_method> method(+, list(purchase_record, purchase_record))
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(purchase_record(combined_name, combined_price))
}
<bytecode: 0x51b89f9f0>
わかりやすいですね。
次に、総称関数printにメソッドを定義しましょう。
# purchase_recordをS4として登録する
S7::S4_register(purchase_record)
S7::method(print, purchase_record) <- function(x){
stringr::str_c(
"あなたは",
stringr::str_c(
x@item_name, "を", x@item_price, "円で", collapse = "、"
),
"買いました。"
)
}
インスタンスの内容を日本語で説明する機能の実装ですね。
> print(buy_apple)
[1] "あなたはりんごを100円で買いました。"
> print(buy_apple + buy_juice)
[1] "あなたはりんごを100円で、ジューズを150円で買いました。"
summary
最後に、今回のお買い物の結果のまとめを回帰分析などでお馴染みの総称関数summaryで実装しましょう。
ちなみに、総称関数によってメソッドを定義するときの引数の名前が変りますが、S7は優しいエラーメッセージを出してくれるので、心配しなくてもいいです。
例えば:
> S7::method(summary, purchase_record) <- function(x){}
Error in conformMethod(signature, mnames, fnames, f, fdef, definition) :
in method for ‘summary’ with signature ‘object="purchase_record"’: formal arguments (object = "purchase_record") omitted in the method definition cannot be in the signature
なるほど、xはダメでobjectを引数にしないといけないですね。
S7::method(summary, purchase_record) <- function(object){
stringr::str_c(
"あなたは",
stringr::str_c(object@item_name, collapse = "と"),
"を買いました。合計金額は",
sum(object@item_price),
"円です。"
)
}
これでできました。
最後に少し複雑なことをしましょう:
> buy_apple <- purchase_record("りんご", 100)
> buy_juice <- purchase_record("ジューズ", 150)
> buy_house <- purchase_record("六本木のマンション", 100000000)
> buy_apple <- purchase_record("りんご", 100)
> buy_juice <- purchase_record("ジューズ", 150)
> # 家も買いましょう笑
> buy_house <- purchase_record("六本木のマンション", 100000000)
> buy_apple
<purchase_record>
@ item_name : chr "りんご"
@ item_price: num 100
> buy_juice
<purchase_record>
@ item_name : chr "ジューズ"
@ item_price: num 150
> buy_house
<purchase_record>
@ item_name : chr "六本木のマンション"
@ item_price: num 1e+08
> print(buy_apple + buy_juice + buy_house)
[1] "あなたはりんごを100円で、ジューズを150円で、六本木のマンションを1e+08円で買いました。"
> # 少し順番を変えます
> summary(buy_house + buy_apple + buy_juice)
[1] "あなたは六本木のマンションとりんごとジューズを買いました。合計金額は100000250円です。"
「+」もprintもsummaryも問題なく想定通りに動作してくれました。
まとめ
いかがでしょうか?
個人的にはRのS3システムと同じようにデータとメソッドを別々で管理するところと、厳密にバリデーションをかけられることにすごく魅力を感じています。
皆さんもぜひリスクを十分に管理している状態でどんどんS7を使ってみて、S7をより良くするための提言をたくさんしましょう!