皆さんはオブジェクト指向でコード書けてる実感ありますか?
クラスだのインターフェースだの、継承だのis-a、has-aだの難しそうな単語ばっかりで…
普通のWebアプリケーションを書き書きしているとあんまり勉強したことを実感したことありませんでした。
ただ、最近ちょうどオブジェクト指向っぽいコードが書けたので、そこだけ切り出して、ライブラリ化して、コードの解説をしようかなってのが話しの内容です。
コードを書くことになったきっかけ
Sentryというエラーがあったときに、エラーの詳細を記録してくれるサービスがありまして。
↓な感じにエラーがあったときに、リクエストパラメータをSentryに送信するようなロジックを書いてました。
あんまり話の本筋じゃないけど、該当のコードのドキュメント
puts params
#> {id: 99999, hoge: "hoge", fuga: "fuga"}
crumb = Sentry::Breadcrumb.new(
request_parameter: params
)
Sentry.add_breadcrumb(crumb)
ただ、このコードはたまに動作しないという欠点がありまして…
base64で変換した文字列みたく、巨大な文字列を含むリクエストパラメータが含まれるとデータが保存されないというSentryの仕様があったのです。
いろんな回避策はあると思います。
今回は巨大な文字列はログとして不要という前提にしましょう。
解決策はSentryにデータを送信する前に巨大な文字列を削って対応しようってのが、コードを書くきっかけです。
どんなコードにしようか
さて、みなさんならどんなコードを書きますか?
問題を5分で終わらせて家に帰るコードはこんな感じかなと思います。
...
params = { id: 1234, hoge: "too_longs_string" }
max_string_length = 5
slimed_params = params.map do |key, value|
if value.kind_of?(String)
[key, value[0, max_string_length]]
else
[key, value]
end
end.to_h
puts slimed_params
#> { id: 1234, hoge: "too_l" }
...
もちろん翌日にはこんな感じに改善しますよ
class StringHashDieter
def self.lose_weight(heavy_hash)
heavy_hash.map do |key, value|
max_string_length = 5
if value.kind_of?(String)
[key, value[0, max_string_length]]
else
[key, value]
end
end.to_h
end
end
params = { id: 1234, hoge: "too_longs_string" }
slimed_params = StringHashDieter.lose_weight(params)
puts slimed_params
#> slimed_params = { id: 1234, hoge: "too_l" }
It works!
課題と改善 その1
いい感じに書けたと思いますか?
残念ながら、このコードには課題が残っています。
params
の中身…ネストするんですよね…
params = { id: 1234, nested_param: { hoge: "too_long_string" } }
その時はそんなパラメータもあるのか…と絶望したんですが…まぁ改善していきましょう
class StringHashDieter
def self.lose_weight(heavy_hash)
heavy_hash.map do |key, value|
max_string_length = 5
if value.kind_of?(String)
[key, value[0, max_string_length]]
elsif value.kind_of?(Hash) # ←new!
[key, StringHashDieter.lose_weight(value)]
else
[key, value]
end
end.to_h
end
end
params = { id: 1234, nested_param: { hoge: "too_long_string" } }
slimed_params = StringHashDieter.lose_weight(params)
puts slimed_params
#> {:id=>1234, :nested_param=>{:hoge=>"too_l"}}
It works!
課題と改善 その2
いい感じに改善できたのでしょうか?
非常に残念ですが…配列が入っているこんなパラメータもあるんです…
params = { id: 1234, nested_param: ["too_long_string", "str"] }
想像以上に対応することが多く、だんだん心が折れそうになりましたが、改善していきましょう…
class StringHashDieter
def self.lose_weight(heavy_hash)
heavy_hash.map do |key, value|
max_string_length = 5
if value.kind_of?(String)
[key, value[0, max_string_length]]
elsif value.kind_of?(Hash)
[key, StringHashDieter.lose_weight(value)]
elsif value.kind_of?(Array) # ←new!
[key, StringArrayDieter.lose_weight(value)]
else
[key, value]
end
end.to_h
end
end
# new!!
class StringArrayDieter
def self.lose_weight(heavy_array)
heavy_array.map do |value|
max_string_length = 5
if value.kind_of?(String)
value[0, max_string_length]
elsif value.kind_of?(Hash)
StringHashDieter.lose_weight(value)
elsif value.kind_of?(Array)
StringArrayDieter.lose_weight(value)
else
value
end
end
end
end
params = { id: 1234, nested_param: ["too_long_string", "str"] }
slimed_params = StringHashDieter.lose_weight(params)
puts slimed_params
#> {:id=>1234, :nested_param=>{:hoge=>"too_l"}}
一気に雲行きが怪しくなってきましたね…
課題と改善?
さてさて、だいぶつらそうなコードになってきましたね。
そんな中、追い打ちをかけるように追加の改善点がありました。
Sentryに送るデータはhashだけじゃなくてarrayかもしれなかったり、単純な文字列だったり、そうじゃなかったりするそうです
ので、同僚から↓な感じに使えるようにしてほしいそうとのことです。
要は最強のDieterで一本化してほしいとのことです。
crumb = Sentry::Breadcrumb.new(
request_parameter: StringDieter.lose_weight(params),
other_data_1: StringDieter.lose_weight(other_data_1), # Hashかもだし、Arrayかもだし、Stringかもだし、Integerかもだし…
other_data_2: StringDieter.lose_weight(other_data_2)
)
Sentry.add_breadcrumb(crumb)
さて、ここまできて、次にどんなコードになるのかなんとなく想像がつくのではないでしょうか?
おそらく、Dieterに渡される引数のクラスを見て、分岐祭りなコードになるとお思いではないでしょうか?
はたして、それでいいのでしょうか?そろそろ方針を変えるときか来たのではないでしょうか?
方針替え
ここでやっと本筋のオブジェクト指向の話になります。
オブジェクト指向にはいろんな格言があります。
そのなかの一つに Don't Tell, Ask - 求めよ、命じるな
という言葉あります。
自分なりに解釈して記述するコードはこんな感じになります。
crumb = Sentry::Breadcrumb.new(
request_parameter: params.slim_down,
other_data_1: other_data_1.slim_down, # Hashかもだし、Arrayかもだし、Stringかもだし、Integerかもだし…
other_data_2: other_data_2.slim_down
)
Sentry.add_breadcrumb(crumb)
とはいえ、こんな感じになったところで何が変わるのでしょうか。
新しい方針のコード
Rubyにはオープンクラスという技法があり、既存のクラスに追加のコードを書けるようになっています。
それを踏まえて実装したコードが下記になります。
class String
def slim_down
max_string_length = 5
self[0, max_string_length]
end
end
class Hash
def slim_down
map { |k, v| [k, v.slim_down] }.to_h
end
end
class Array
def slim_down
map(&:slim_down)
end
end
# ObjectクラスはInteger等のすべてのクラスの親玉。nilもObjectのクラスに含まれる
class Object
def slim_down
self
end
end
使い方はこんな感じです
>> "too_long_string".slim_down
=> "too_l"
>> 100000.slim_down
=> 100000
>> nil.slim_down
=> nil
>> ["too_long_string", "a", 100000,["too_long_string",200000]].slim_down
=> ["too_l", "a", 100000, ["too_l", 200000]]
>> { too_long_string: "too_long_string", in: { too_long_string: "too_long_string" } }.slim_down
=> { too_long_string: "too_l", in: { too_long_string: "too_l" } }
どうでしょうか?
所見だと、コードの振る舞いを理解するのが難しいかもしれません。
とはいえ、あんなに大量にあった分岐のコードががっつり減り、だいぶシンプルなコードになりました。
メソッドもだいぶ使いやすくなったのではないでしょうか?
オブジェクト指向のポリモーフィズム的な思想だったり、rubyのダックタイピングの思想をうまく表現できたかなと思っています。
これがオブジェクト指向のパワーと言ってもいいのではないでしょうか?
感想
気軽にオープンクラスするなとか、まだまだいろんな意見はあるかもしれませんが、自分の中で勉強した内容が目に見えるわかる形でコードで表現できたことがうれしかったです。
いろんな本を読みつつ満足のいくコードを書けたことは少ないので、自分のプログラマとしての成長を感じました。
特にメタプログラミングRubyという本は難しいのですが、何度も読み続けて、血肉にするとめちゃんこ強い武器になると思います。
書籍内にあるオープンクラスなんていつ使うんだよとかその当時は思ってました。
この記事の趣旨は勉強を続けているとこういうコードがかけるようになるよという応援メッセージが8割の要素と2割の承認欲求という実はエモい記事です
みなさんも気持ちよく書けたコードがあれば、是非共有してみてはいかがでしょうか?