Lock in $30 Savings on PRO—Offer Ends Soon! ⏳

入門『状態』#kaigionrails / "state" for beginners wit...

ShinkuFencer
October 26, 2024

入門『状態』#kaigionrails / "state" for beginners with Rails

Kaigi on Rails 2024 Day2 で発表した内容です。

【スライド末尾で紹介されているURL】
Rails: ビューには可能な限りロジックを書かないこと(翻訳)|TechRacho by BPS株式会社 https://techracho.bpsinc.jp/hachi8833/2023_03_10/55778

書くときにひと呼吸おいて考えてから 書いてほしいRailsコードの書き方
https://speakerdeck.com/shinkufencer/rails-programing-code-that-i-hope-you-will-consider-if-you-really-need-it

状態論 (1) | Marginalia
https://blog.lacolaco.net/posts/theory-of-state-01/

ShinkuFencer

October 26, 2024
Tweet

More Decks by ShinkuFencer

Other Decks in Technology

Transcript

  1. はじめに:今回取り上げる『状態』について 2 • 『状態』とは聞いたりするけど、人によっては身近に感じることは 無いかもしれません。 • また、『状態』が多いとつらい、という話を聞いたりもしますが ピンとこないこともあるかと思います。 • そこで今回はオブジェクトにまつわる『状態』を起点にして

    身近にある『状態』について知ってもらい 普段のRails開発にも利用できる話をできればと思います。 • ターゲットは初学者の方を想定しています。 プログラミングでの『状態』に触れる足がかりになれば幸いです。
  2. 電球を表現した LightBulb クラス 6 class LightBulb attr_accessor :is_on def initialize

    @is_on = false end end インスタンス変数 @is_on で 電球が点いているか、いないかを表現
  3. 明るさを追加した LightBulb クラス 8 class LightBulb attr_accessor :is_on, :brightness def

    initialize @is_on = false @brightness = 0 end end 明るさを表す要素として @brightnessを新規で追加 0〜100を入れてもらう想定
  4. 『状態』にまつわる話がつらくなっているコード 13 # インスタンス変数 @bulb と @bulb_message を初期化するメソッド # @param

    [Integer] default_brightness 初期設定したい明るさの値 (%)、0から100を想定 def setup(default_brightness:) @bulb = LightBulb.new if @bulb.nil? @bulb_message = "明るさ:電球の明度は普通です。 " @bulb.is_on = true @bulb.brightness = 50 if default_brightness > 0 @bulb.is_on = true @bulb.brightness = default_brightness if @bulb.brightness >= 70 @bulb_message = "明るさ:電球の明度が高いです。 " elsif @bulb.brightness <= 30 @bulb_message = "明るさ:電球の明度が低いです。 " end elsif default_brightness == 0 @bulb.is_on = false @bulb.brightness = 0 @bulb_message = "明るさ:電球はOFFです。" end end ▼初期セットアップの仕様 ・LightBulbとメッセージをインスタンス変数  としてセットアップしていく ・LightBulbの明るさの初期値として設定できる ・初期値が0の場合は電球はOFFの状態とする。 ・初期値とともに明るさのメッセージもつくる  明るさのメッセージは以下の通り  ・明るさが70以上だと明度が高いメッセージ  ・明るさが30以下だと明度が暗いメッセージ  ・それ以外の場合は明度が普通であるメッセージ  ・電球がOFFの場合はOFFであるメッセージ
  5. @bulbのインスタンス変数を何度も書き換えている 15 def setup(default_brightness:) @bulb = LightBulb.new if @bulb.nil? @bulb_message

    = "明るさ:電球の明度は普通です。 " @bulb.is_on = true @bulb.brightness = 50 if default_brightness > 0 @bulb.is_on = true @bulb.brightness = default_brightness if @bulb.brightness >= 70 @bulb_message = "明るさ:電球の明度が高いです。 " elsif @bulb.brightness <= 30 @bulb_message = "明るさ:電球の明度が低いです。 " end elsif default_brightness == 0 @bulb.is_on = false @bulb.brightness = 0 @bulb_message = "明るさ:電球はOFFです。" end end @bulbのオブジェクトが持つ インスタンス変数を 処理の途中で適宜再代入しているので 一見して is_on と brightness がわからず 状態が把握しづらい
  6. @bulb_messageも都度再代入をしている 16 def setup(default_brightness:) @bulb = LightBulb.new if @bulb.nil? @bulb_message

    = "明るさ:電球の明度は普通です。 " @bulb.is_on = true @bulb.brightness = 50 if default_brightness > 0 @bulb.is_on = true @bulb.brightness = default_brightness if @bulb.brightness >= 70 @bulb_message = "明るさ:電球の明度が高いです。 " elsif @bulb.brightness <= 30 @bulb_message = "明るさ:電球の明度が低いです。 " end elsif default_brightness == 0 @bulb.is_on = false @bulb.brightness = 0 @bulb_message = "明るさ:電球はOFFです。" end end @bulb_messageに関しても 再代入をする箇所が多いので 最終的な値がわかりにくい
  7. attr_accessorなので入ってくる値もタイミングも自由 18 class LightBulb attr_accessor :is_on, :brightness def initialize @is_on

    = false @brightness = 0 end end 仕様としては0〜100と定めているが 特段制限は設けられていない
  8. 0〜100以外の値が入ることが考慮されていない 19 def setup(default_brightness:) @bulb = LightBulb.new if @bulb.nil? @bulb_message

    = "明るさ:電球の明度は普通です。 " @bulb.is_on = true @bulb.brightness = 50 if default_brightness > 0 @bulb.is_on = true @bulb.brightness = default_brightness if @bulb.brightness >= 70 @bulb_message = "明るさ:電球の明度が高いです。 " elsif @bulb.brightness <= 30 @bulb_message = "明るさ:電球の明度が低いです。 " end elsif default_brightness == 0 @bulb.is_on = false @bulb.brightness = 0 @bulb_message = "明るさ:電球はOFFです。" end end @bulb.brightnessは状態として 0〜100以外の値も取りうるので その点を考慮する必要がある
  9. つらさ抑制①『状態』の変化を可能な限りさせない • 変数に着目したときに再代入が多いと最終的な値がわかりづらいです。 そのため、再代入をさせないようにすると変化が追いやすくなります。 下記の例はmessageの再代入をなくしてみる例です。 • @bulbにも類似のアプローチを適用することで変化を減らせます。 24 def create_message(sei,

    mei) message = "呼び出し:" if !sei.nil? && mei.nil? message += sei else message += "#{sei}#{mei}" end message += "さん" if sei.nil? && mei.nil? message = "お名前不明の呼び出し " end return message end def create_message(sei, mei) is_only_sei = !sei.nil? && mei.nil? is_no_seimei = sei.nil? && mei.nil? name = is_only_sei ? sei : "#{sei}#{mei}" no_name_mesage = "お名前不明の呼び出し" name_message = "呼び出し:#{name}さん" message = is_no_seimei ? no_name_mesage : name_message return message end
  10. Before: @bulbが持つインスタンス変数を何度も再代入している 25 def setup(default_brightness:) @bulb = LightBulb.new if @bulb.nil?

    @bulb_message = "明るさ:電球の明度は普通です。 " @bulb.is_on = true @bulb.brightness = 50 if default_brightness > 0 @bulb.is_on = true @bulb.brightness = default_brightness if @bulb.brightness >= 70 @bulb_message = "明るさ:電球の明度が高いです。 " elsif @bulb.brightness <= 30 @bulb_message = "明るさ:電球の明度が低いです。 " end elsif default_brightness == 0 @bulb.is_on = false @bulb.brightness = 0 @bulb_message = "明るさ:電球はOFFです。" end end
  11. After: 書き換えるのを1回だけに 26 def setup(default_brightness:) @bulb = LightBulb.new if @bulb.nil?

    @bulb_message = "明るさ:電球の明度は普通です。 " light_on = default_brightness > 0 ? true : false @bulb.is_on = light_on @bulb.brightness = light_on ? default_brightness : 0 if default_brightness > 0 if @bulb.brightness >= 70 && @bulb.brightness <= 100 @bulb_message = "明るさ:電球の明度が高いです。 " elsif @bulb.brightness <= 30 && @bulb.brightness > 0 @bulb_message = "明るさ:電球の明度が低いです。 " end elsif default_brightness <= 0 @bulb_message = "明るさ:電球はOFFです。" end end If文の中に混じっていた変更を 一箇所に集約することで 変化させる部分を最低限にできた
  12. After: 書き換えるのを1回だけに 27 def setup(default_brightness:) @bulb = LightBulb.new if @bulb.nil?

    light_on = default_brightness > 0 ? true : false @bulb.is_on = light_on @bulb.brightness = light_on ? default_brightness : 0 @bulb_message = if @bulb.brightness >= 70 && @bulb.brightness <= 100 "明るさ:電球の明度が高いです。 " elsif @bulb.brightness <= 30 && @bulb.brightness > 0 "明るさ:電球の明度が低いです。 " elsif @bulb.brightness <= 0 "明るさ:電球はOFFです。" else "明るさ:電球の明度は普通です。 " end end @bulb_messageも 1回の代入で済むような 書き方に変更をする
  13. Before: どこからでもどのようにでも値が変更可能 29 class LightBulb attr_accessor :is_on, :brightness def initialize

    @is_on = false @brightness = 0 end end attr_accessorなので どこからどのようにでも値が変更可能で 設定する側も参照する側も入る値の予測がしづらい
  14. # 電球をオンにする # @param [Integer] brightness オン時の明るさ def turn_on(brightness: 50)

    @is_on = true tune_brightness(brightness) end # 電球をオフにする def turn_off @is_on = false @brightness = 0 end def tune_brightness(level) is_over_range = level > 100 || level < 0 set_value = is_over_range ? 0 : level # 範囲外は0 @brightness = set_value end end After: 値を変更するルートと幅を限定的にした 30 class LightBulb attr_reader :is_on, :brightness # 初期状態では電球はオフで、明るさは0 def initialize @is_on = false @brightness = 0 end attr_readerにして 直接変更は不可に オブジェクト外から変更する場合は メソッド経由にし、変更方法を制限する また、意図から外れる数値の対応策もして 変更できる範囲に制約をかけれるようにする
  15. つらさ抑制③ 『状態』をシンプルに少なく保つ • LightBulbが持つ『状態』は電源ON/OFFと明るさ0〜100%をかけ合わせ 202パターン表現することが可能になっています。 • しかし、現時点の使い方を鑑みると『状態』のパターン表現は 以下の4つだけでも充分そうです。 ◦ 「電源が点いていない」

    ◦ 「電源が点いていて、明度が高い」 ◦ 「電源が点いていて、明度が低い」 ◦ 「電源が点いていて、明度が普通」 • そのため、直接的に値を見て『状態』を判断する形式をやめて LightBulbに上記 4つのパターンが判断できるメソッドを加えてみます。 31
  16. # 明度が高いかを判定 def high_brightness? @is_on && @brightness >= 70 &&

    @brightness <= 100 end # 明度が低いかを判定 def low_brightness? @is_on && @brightness > 0 && @brightness <= 30 end # 明度が普通かを判定 def normal_brightness? @is_on && @brightness > 30 && @brightness < 70 end # 電球がオフかを判定 def off? !@is_on end end After:『状態』の読み取らせかたを変えたLightBulb 32 class LightBulb def initialize @is_on = false @brightness = 0 end def turn_on(brightness: 50) @is_on = true tune_brightness(brightness) end def turn_off @is_on = false @brightness = 0 end def tune_brightness(level) is_over_range = level > 100 || level < 0 set_value = is_over_range ? 0 : level @brightness = set_value end attr_readerで変数を見て『状態』を読み取らせるのをやめ 『状態』を読み取るためのメソッドを用意する
  17. # 明るさの状態に応じてメッセージを生成 # @param [LightBulb] bulb メッセージを作る基準となる電球 def build_bulb_message(bulb) header_message

    = "明るさ:" return "#{header_message}電球の明度が高いです。" if bulb.high_brightness? return "#{header_message}電球の明度が低いです。" if bulb.low_brightness? return "#{header_message}電球の明度は普通です。" if bulb.normal_brightness? return "#{header_message}電球はOFFです。" if bulb.off? "" # この状態は存在し得ないが明示的に空文字を返却する end After: 変更したLightBulbを利用したsetup処理 34 def setup(default_brightness:) @bulb = LightBulb.new if @bulb.nil? if default_brightness > 0 @bulb.turn_on(brightness:default_brightness) else @bulb.turn_off end @bulb_message = build_bulb_message(@bulb) end 状態を示す書き方がシンプルになり 直感的にも処理的にも 見通しが良くなった
  18. EventsControllerのindexアクション 40 class EventsController < ApplicationController def index @list =

    Event.where(is_public: true) # クエリストリングでonly_special=1が指定されている場合は更に絞り込む if params[:only_special] == 1 @list = Event.where(is_public: true, category: 1) end # クエリストリングでforce_nothing_result=1の場合は何も表示しない if params[:force_nothing_result] == 1 @list = [] end end end @listに再代入を繰り返している。 そのため最終的にどんな状態になるか 上から下まで読まないと把握ができない
  19. After: @listの再代入をなくしたindexアクション 42 class EventsController < ApplicationController def index is_special

    = params[:only_special] == 1 is_force_nothing = params[:force_nothing_result] == 1 @list = making_list(is_special, is_force_nothing) end def making_list(is_only_special, is_force_nothing) return [] if is_force_nothing return Event.where(is_public: true, category: 1) if is_only_special Event.where(is_public: true) end end @listが代入されるのは1回のみに @listの内容を決定するための情報を引数とした 中身の作成を担うメソッドを用意
  20. EventのindexアクションのView 44 <% @list.each do |event| %> <% ## 中略

    ## %> <% if event.open_at > Time.zone.now %> 開催期間前です。 <% elsif event.close_at < Time.zone.now %> 開催が完了しました。 <% else %> <% if event.open_at <= Time.zone.now && event.end_at >= Time.zone.now && event.type == 1 %> 開催期間中のスペシャルイベントです。 <% elsif event.open_at <= Time.zone.now && event.end_at >= Time.zone.now && event.type == 0 %> 開催期間中の通常イベントです。 <% else %> 開催中のイベントです。 <% end %> <% end %> <% end %> 長い判定式がいくつもあるので 一見してどういうことをしているかがわかりにくい
  21. After: Modelで『状態』をわかるようにし、Viewもテコ入れ 46 class Event < ApplicationRecord def before_open? open_at

    > Time.zone.now end def finished? end_at < Time.zone.now end def in_progress? now = Time.zone.now open_at <= now && end_at >= now end def normal? category == 0 end def special? category == 1 end end 判定文であった内容を Modelのメソッド化して 明確に状態として定義する <% @list.each do |event| %> <% ## 中略 ## %> <% if event.before_open? %> 開催期間前です。 <% elsif event.finished? %> 開催が完了しました。 <% else %> <% if event.in_progress? && event.special? %> 開催期間中のスペシャルイベントです。 <% elsif event.in_progress? && event.normal? %> 開催期間中の通常イベントです。 <% else %> 開催中のイベントです。 <% end %> <% end %> <% end %>
  22. 事例③用途が迷子になったControllerインスタンス変数 • 例えば右記のような ControllerとViewがある場合 • 一見すると問題ないように 見えますが、Viewでは @userは利用していませんし @postsもカウント数を 利用しているだけです。

    • 改修を加えていくと何のための インスタンス変数かが把握できず 考慮すべきかわからなくなります 49 class UsersController < ApplicationController def show @user = User.find(params[:id]) @posts = @user.posts end def edit @user = User.find(params[:id]) end end この人の投稿数は<%= @posts.size %>です。 app/controllers/users_controller.rb app/views/users/show.html.erb
  23. 事例③用途が迷子になったControllerインスタンス変数 • 本当に必要なのはcountだけなので インスタンス変数もcountを 表現するためのだけのものに 変更しました。 • 必要なものだけに絞られたので Controllerとして持っている 『状態』がシンプルになりました

    50 class UsersController < ApplicationController def show user = User.find(params[:id]) @post_count = user.posts.count end def edit @user = User.find(params[:id]) end end この人の投稿数は<%= @post_count %>です。 app/controllers/users_controller.rb app/views/users/show.html.erb