Ruby

Ruby2系はチームの幸福度を最大化できなかった

背景

Ruby2系の良さは、ダックタイピングによって 柔軟に書くことができ、プログラマへの負荷を最小限にしています。
そのためRubyはプログラマへの幸福度を最適化しているといえます。
しかし、最近ではそのプログラマというのは個人レベルであって、プログラマのチームへの幸福度は最適化されていないのではないかと思い始めました。

一般的なRubyプログラマのチーム

一般的なRubyプログラマのチームでは以下の様な取り組みを行うことがスタンダードになりつつあります。

  • 複数人による綿密なコードレビュー
  • 美しく書こうという文化、リファクタリング
  • テストを書いてCIで実行する
  • Rubocop、Brakemanなどの静的解析ツールの導入

これらのことを行うと思うのですが、段々と以下のような問題に直面してくると思います

  • 大規模になり、コードレビュー自体に時間がかかる
  • コードレビューの質が下がっていく
  • 選択肢が多いRubyで美しく書こうという基準を作るのが難しい
  • テストが多くなり、CIに費やす時間やコストが増える
  • 静的解析の違反基準レベルを落としていって段々と無法地帯になる

と言った感じでRubyを書き続けていると、こういったプログラミングと間接的に関係するような要因にだんだんと頭を悩ませてきます。
Rubyは開発者が楽しくプログラミングできるように開発されているはずなのに、なんでこういうことが起きてしまうのか、考えてみました。

Rubyで苦しくなる原因

他人のコードは推定しなければならない

def publish(article)
  article.publish
end

上記のようなコードに対して変更を加えなければならないとすると、以下のことを考慮します。

  1. 引数の型は何かを調べる
  2. 返り値の型は何か調べる
  3. このメソッドはどのように使われているかを調べる
  4. どのように変更すると良いのか、保守性、影響範囲などを考慮して考える
  5. 実際に変更する
  6. コードレビューで型の整合性、影響範囲などをチェックする

というようにコード単体だと何をやっているのかわからないので、他のコードを参照するなどして推定しなければならないです。

このコードを静的型付けの言語で書くと

fun publish(article: Article): Any? {
    article.publish()
}   

となります。上記のようなコードに対して変更を行う場合、

  1. 引数の型は何かを調べる
  2. 返り値の型は何か調べる
  3. このメソッドはどのように使われているかを調べる
  4. どのように変更すると良いのか、保守性、影響範囲などを考慮して考える
  5. 実際に変更する
  6. コードレビューで型の整合性、影響範囲などをチェックする

というように型の整合性について考え無くて済みます。
Rubyではダックタイピングできる代わりに、他人のコードを読むのが静的型付けの言語と比べて手間がかかります。

Rubyでの解決策

コード規約を作る

返り値の型に応じてメソッドの書き方を決めるというのが代表的です。

def published?
  status == 'published'
end

上記のようにtrueかfalseを返す場合は?を末尾につけるというルールは有名です。

Rdocなどドキュメントを作る

クラスやメソッドの上にコメントを書いておくと楽になるかもしれません。

ActiveSupportのpluckメソッドの例

  # Convert an enumerable to an array based on the given key.
  #
  #   [{ name: "David" }, { name: "Rafael" }, { name: "Aaron" }].pluck(:name)
  #   # => ["David", "Rafael", "Aaron"]
  #
  #   [{ id: 1, name: "David" }, { id: 2, name: "Rafael" }].pluck(:id, :name)
  #   # => [[1, "David"], [2, "Rafael"]]
  def pluck(*keys)
    if keys.many?
      map { |element| keys.map { |key| element[key] } }
    else
      map { |element| element[keys.first] }
    end
  end

どのように動作するかを書いておくと、より親切です。
ただし、変更がたくさん行われるメソッドの場合、コメントの保守コストが発生するため注意が必要です。

テストを書く

テストを書くというのはRubyを書く上で常識だと思います。
ただし、コンパイルする言語の場合は、コンパイル時にコードが矛盾なく書かれていることを保証してくれますが、
Rubyの場合は、テストで書いた部分だけは矛盾なく書かれていることしか保証してくれません。

よってどこまでテストを書くのかが非常に難しい問題になります。

IDEを入れる

RubyMine、IntelliJ IDEA等が便利です。

スクリーンショット 2018-03-13 18.22.10.png

メッソド名右クリックでFind Usagesを実行するとメソッドが使われている場所を検索してくれるので、
引数に何が使われているのかを簡単に調べることができます。

これ以外にも様々な機能があり、ある程度バグやエラーを未然に防ぐことができます
個人的な意見ですが、Railsのプロダクトには必須だと思います。

型の不整合によるエラーが多発する

Rubyを使っていて、NoMethodErrorとの遭遇が非常に多かったです。
このエラーの原因としてはオブジェクトの方の整合性があっていないときに発生します。
Rubyはコンパイルが無く、実行時に方の整合性をチェックするため、型を意識せずにプログラムを書くと発生します。

オブジェクトがnilであるケース

JavaのNullPointerExceptionと似ています。

def method(string)
  string.length
end
  • 正しい実行結果
string = 'string'
method(string)
=> 6
  • nilの時の実行結果
string = nil
method(string)
=> NoMethodError: undefined method `length' for nil:NilClass

オブジェクトが異なるケース

型の不整合です。

def method(num)
  1 + num
end
  • 正しい実行結果
num = 1
method(string)
=> 6
  • 数値でない時の実行結果
num = 'string'
method(string)
=> TypeError: String can't be coerced into Integer

何が問題なのか?

def method(string)
  string.length
end

上記のRubyコードを静的型付けの言語で書き直すと以下のようになります

fun method(string: Any?): Any? {
    return string!!.length
}

問題点としては以下が挙げられます。

  • 引数の型がわからない
  • 返り値の型がわからない
  • NULL安全でない

解決策

引数のチェックを慎重に行う

ActiveRecordのfindメソッドの場合

      def find(*ids) # :nodoc:
        # We don't have cache keys for this stuff yet
        return super unless ids.length == 1
        return super if block_given? ||
                        primary_key.nil? ||
                        scope_attributes? ||
                        columns_hash.include?(inheritance_column)

        id = ids.first

        return super if id.kind_of?(Array) ||
                         id.is_a?(ActiveRecord::Base)

        key = primary_key

        statement = cached_find_by_statement(key) { |params|
          where(key => params.bind).limit(1)
        }

        record = statement.execute([id], self, connection).first
        unless record
          raise RecordNotFound.new("Couldn't find #{name} with '#{primary_key}'=#{id}",
                                   name, primary_key, id)
        end
        record
      rescue ::RangeError
        raise RecordNotFound.new("Couldn't find #{name} with an out of range value for '#{primary_key}'",
                                 name, primary_key)
      end

上記のように慎重に引数のチェックを行うことで、型の不整合のエラーはある程度防げます。
ただし、コードが冗長になってしまうことや、全ての引数の型のパターンを網羅するのは大変という問題があります。

そのような結果・・・

def picture_url(article)
  return nil if article.nil?
  return nil if article.picture.nil?
  article.picture.url
end
def picture_url(article)
  article&.picture&.url
end

といったように、しつこくnil?、blank?、present?を書くコードは、頻繁に目にします。
複数人のチームで開発する以上、nilの混入は無いとは言えないので仕方ない気もします。

再代入や再定義

Rubyでは以下のようなコードを書けてしまいます。

def method(string)
  result = 1

  if string.nil?
    result = 0
  end

  if string == 'published'
    result = true
  end

  result
end

上記のコードの問題点として、

  • resultという変数の内容が、何回も変わっている
  • resultという変数の型が、何回も変わっている

これによって、コードの理解が難しくなり、このメソッドを用いた箇所では型の不整合によるエラーが発生してしまいます。

解決策としては

  • 上記のようなコードを禁止する
  • コーディング規約を作る
  • プログラマのスキルレベルを上げる

といった人による方法でしか防げません。

結論

Rubyは実行時に型の整合性をチェックするため、大規模になればなるほどバグが発生する可能性が高くなり、保守が大変になります

Rubyをチームに導入するメリットとしては

  • 柔軟に書けるため、単純なスクリプトの作成などは高速に書ける
  • メソッドが親切に用意されているため、型を意識しなくても自然にメソッドを呼び出せる(lengthなど)
  • 保守のために、コードレビュー、スクラムといったコミュニケーションやマネジメント寄りの方法がほぼ必須なので、導入のきっかけになる

Rubyをチームに導入するデメリットとしては

  • 型を書く必要がない分、考慮しなければいけないため、簡単そうに見えて扱うのは難しい言語
  • コードレビュー、テスト、きれいに書く、リファクタリングといった文化も一緒に導入しなければいけないのでマネジメント能力も必要

以下は個人的な予測ですが、

  • Ruby2系を採用するのは徐々に減っていく
  • Ruby3系の型や静的解析が、チームとしての幸福度を上げるものであったら、また増えていく
  • 型推論、Null安全などの言語に徐々にシフトしていく

と考えています。
Rubyの辛いところへの根本的対策が、マネジメントや意識改革といった人に依存したエコシステムに比重を置きすぎているとも言えます。
型推論、Null安全の言語へ切り替えたとしても、人に依存したエコシステムは存在し続けますが、少なくとも負荷は減るのではないかと思います。
Ruby3系ではこれらのことが意識されている感じがするので、解決されることを期待しています。