Hash#key? を上書きしてもキーワード引数は上書きできない

Ruby のしくみに面白いコードが書かれていました。

class Hash
  def key?(val)
    puts "Looking for key #{val}"
    false
  end
end

def add_two(a: 2, b: 3)
  a+b
end

puts add_two(a: 1, b: 1)
# Looking for key a
# Looking for key b
# 5

キーワード引数は内部で Hash#key? を使っているので、上書きすると挙動が変わるという内容。

面白そうなので、実際に試してみた。

Ruby 2.6.0-dev

サンプルコードを写経して、 2.6.0-dev で動かしてみた。

$ ruby test.rb
2

あれ、結果が違う・・・。 dev じゃなくて、最新の 2.5 系なら動くのかな?

Ruby 2.5.1

$ ruby test.rb
2

これも動かないのか・・・。 過去バージョンのどこかで挙動が変わったのかな。

どのバージョンで変わったのか調べてみた

# 2.4.4
2

# 2.3.7
2

# 2.2.10
2

# 2.2.0
2

# 2.1.10
Looking for key a
Looking for key b
5

2.2系で仕様が変わったっぽい。

変更のコミットを探してみた

git log --no-merges -p v2_1_0..v2_2_0 から関係ありそうなコミットを探してみました。

関係ありそうなコミットを探したところ、 ko1 さんが keyword argument 周りを大きく書き換えているコミットがあったので、これで挙動が変わったのかも?(自信はない)

https://github.com/ruby/ruby/commit/fbebd502f9f374d1eef31c63c10c7d8adcd63280

まとめ

書籍を読んだあと、自分で試すの大事ですね。

もっと Ruby の面白い挙動を知りたい人は「Rubyのしくみ」を読むのがオススメです。

Rubyのしくみ -Ruby Under a Microscope-

Rubyのしくみ -Ruby Under a Microscope-

Rubyのdefの後に何を書けるか調べる実験

実務では一切の役に立たないRuby雑学。

普通のdef

まずは基本の書き方。

def foo
  puts "hello"
end

foo
#=> "hello"
class User
  def self.foo
    puts "foo"
  end

  class << self
    def bar
      puts "bar"
    end
  end
end

User.foo
#=> "foo"

User.bar
#=> "bar"

特異クラスの別の書き方

やってる事は同じだけど、selfをUserに変えたやつ。

class User
  class << User
    def bar
      puts "bar"
    end
  end
end

def User.foo
  puts "foo"
end


User.foo
#=> "foo"

User.bar
#=> "bar"

self以外のメソッド経由

class User
  def self.this
    User
  end

  def this.foo
    puts "foo"
  end
end

User.foo
#=> "foo"

インスタンスの特異メソッド

class User; end

user = User.new
def user.foo
  puts "foo"
end
user.foo
#=> "foo"

other = User.new
other.foo
#=> (NoMethodError)

.(ドット)を複数書く

シンタックスエラーになる。

# a.rb:1: syntax error, unexpected '.', expecting ';' or '\n'
def a.b.c

三項演算子

括弧で囲めば動く。

class User; end
class Admin; end

def (ENV.key?("CI") ? User : Admin).foo
  puts "foo"
end

Admin.foo
#=> "foo"

User.foo
#=> (NoMethodError)

Procの呼び出し

括弧で囲めば動く。

class A; end
find_class = -> { A }

def (find_class.call).foo
  puts "foo"
end
A.foo
#=> "foo"

リテラルを直置き

シンタックスエラーになる。

# a.rb:1: can't define singleton method for literals
def (1).foo
# a.rb:1: can't define singleton method for literals
def ("").foo
# a.rb:1: can't define singleton method for literals
def (:a).foo
# a.rb:1: can't define singleton method for literals
def ([]).foo

Hashのリテラルを括弧で囲む

おや...?

# no error
def ({}).foo
  puts "foo"
end

# 別インスタンスなのでメソッドは呼べない
{}.foo
#=> (NoMethodError)

Hashだけシンタックスエラーじゃないのは不思議。

Rubyのdefの中で更にdefをするコードの挙動

defの中にdefを書いたらどうなるのか? そんな実験。

class Nanoha
  def starlight_breaker
    puts "魔力が足りません"
  end

  def battle!
    def starlight_breaker
      puts "全力全開、スターライトブレイカー!"
    end
  end
end

user = Nanoha.new

user.starlight_breaker
#=> (1)

user.battle!
user.starlight_breaker
#=> (2)

other = Nanoha.new
other.starlight_breaker
#=> (3)

battle! を実行したら、 starlight_breaker のメソッドが上書きされて収束砲を発射できそうなコードです。

さて、このコードを実行すると、(1)〜(3)では何が出力されるでしょう?

自信ある人は一度考えてみてください。回答はスペースを空けた下の方に書いてあります。

.
.
.
.
.
.
.
.
.
.
.
.

出力結果

実行結果は下記の通りです。

$ ruby nanoha.rb
魔力が足りません
全力全開、スターライトブレイカー!
全力全開、スターライトブレイカー!

新しいインスタンスを作っているのに、(3)でも「スターライトブレイカー!」になってしまいました!

解説

Rubyのレキシカルスコープの話なので、ちゃんと理解したい人は「Rubyのしくみ」を読むと良いです。

Rubyのしくみ -Ruby Under a Microscope-

Rubyのしくみ -Ruby Under a Microscope-

もしインスタンスの特異メソッドにしたいのであれば、 def self.starlight_breakerself をつけると下記のような挙動になります。

class Nanoha
  def starlight_breaker
    puts "魔力が足りません"
  end

  def battle!
    def self.starlight_breaker
      puts "全力全開、スターライトブレイカー!"
    end
  end
end

user = Nanoha.new

user.starlight_breaker
#=> 魔力が足りません

user.battle!
user.starlight_breaker
#=> 全力全開、スターライトブレイカー!

other = Nanoha.new
other.starlight_breaker
#=> 魔力が足りません

いや、defの中にdef書くコードとかプロダクトでは書いたらダメですよ?

特異クラスの中に定義したクラスのancestors

class A
  class << self
    class B
    end
  end
end

p A.singleton_class.const_get(:B).ancestors
#=> [#<Class:0x00007fe8c203c980>::B, Object, Kernel, BasicObject]

特異クラスの #<Class:A> とは別に #<Class:0x00007fe8c203c980> が存在する・・・?

このオブジェクトの招待

Twitterのリプライで教えて頂いた。 どうやら表示が違うだけで Aの特異クラス でした。

試したコード

class A
  class << self
    class B
    end
  end
end

p A.singleton_class
#=> #<Class:A>
p A.singleton_class.const_get(:B).ancestors
#=> [#<Class:0x00007fd05b0ec6e8>::B, Object, Kernel, BasicObject]

require 'fiddle'
p Fiddle.dlwrap(A.singleton_class).to_s(16)
#=> "7fd05b0ec6e8"

また1つ、Ruby雑学力が増えた。 しかし、こんなコードは仕事で絶対に会いたくない。

Rubyのモジュールでnewを使う方法

タイトルは半分くらい釣りです。役に立たないRuby雑学です。

コード例

モジュールで self.new を定義すると、モジュールに対してnewが呼べる。

class A
end

module M
  def self.new
    A.new
  end
end

p M.new.class
#=> A

別のクラスのインスタンスを返すクラス

同じようにクラスで self.new を定義しておけば、別のクラスのインスタンスを返すことも可能になる。

class A
end

class B
  def self.new
    A.new
  end
end

p B.new.class
#=> A

読みづらいので、こんなコードは書いたらダメです。絶対にダメですよ!

RuboCopの新しいルールを追加する方法(Custom Copの作り方)

何度もレビューで指摘することはRuboCopのルールとして追加して、自動化しておくと便利です。

この記事では、RuboCopで新しいルールを作る方法を紹介します。

参考にしたページ

これがとても参考になった。

github.com

Copの書き方は独特なので、RuboCopリポジトリlib/rubocop/cop ディレクトリにある他Copをいくつか読むと参考になります。

github.com

Custom Cop の作り方

ここでは例としてMatzの名前を "Hiroyuki Matsumoto" と間違って書いたとき、検知できるCopを作ってみます。

まずは .rubocop.ymlrequire の行を追加します。

require: './lib/custom_cops/matz_name'

AllCops:
  # 以下略...

次に、 lib/custom_cops/matz_name.rb に新しいルールを書きます。

# frozen_string_literal: true

module CustomCops
  # @example
  #   # bad
  #   MATZ_NAME = "Hiroyuki Matsumoto"
  #
  #   # good
  #   MATZ_NAME = "Yukihiro Matsumoto"
  class MatzName < RuboCop::Cop::Cop
    BAD_NAME = "Hiroyuki Matsumoto"
    GOOD_NAME = "Yukihiro Matsumoto"
    MSG = "Use '#{GOOD_NAME}' instead of '#{BAD_NAME}'."

    def on_str(node)
      add_offense(node) if node.source.include?(BAD_NAME)
    end

    def autocorrect(node)
      ->(corrector) {
        new_code = node.source.gsub(BAD_NAME, GOOD_NAME)
        corrector.replace(node.source_range, new_code)
      }
    end
  end
end

いくつか独特なルールがあるので、簡単に説明します。

on_xxx メソッドはRubyのコード内で該当するコードが見つかった時に呼ばれるメソッドです。 たくさんあるので興味ある人はRuboCopリポジトリlib/rubocop/ast/traversal.rbを読むと良いです。 基本的には「それっぽいメソッドを定義して、テスト書いてみて試す」とか「他Copの実装をパクる」で頑張ります。

add_offenseはルール違反を検知した時に呼び出します。 引数に渡している node に対して、MSG 定数の文字列をメッセージとして表示します。 メッセージを動的に変えたい場合は message オプションで変える事もできます。

add_offense(node, message: "foo")

autocorrect メソッドは実装すると --auto-correct に対応することができます。 コード例のように proc を返すようにする必要があります。

テストの書き方

spec/rails_helper.rb で下記のようにCustom Copを読み込むコード、RSpecのヘルパーメソッドの読み込みを行います。

require "rubocop"
Dir[Rails.root.join("lib/custom_cops/**/*.rb")].each { |f| require f }
require "rubocop/rspec/support"

RSpec.configure do |config|
  config.include(RuboCop::RSpec::ExpectOffense)
end

RuboCopリポジトリのテストコードを参考にしながら、コードを書きます。

# frozen_string_literal: true

require "rails_helper"

RSpec.describe CustomCops::MatzName do
  subject(:cop) { described_class.new }

  it "registers an offense" do
    expect_offense(<<-RUBY.strip_indent)
      MATZ_NAME = "Hiroyuki Matsumoto"
                  ^^^^^^^^^^^^^^^^^^^^ Use 'Yukihiro Matsumoto' instead of 'Hiroyuki Matsumoto'.
    RUBY
  end

  it "autocorrects" do
    new_source = autocorrect_source('MATZ_NAME = "Hiroyuki Matsumoto"')
    expect(new_source).to eq 'MATZ_NAME = "Yukihiro Matsumoto"'
  end
end

できればRuboCopにプルリクを投げよう

もし有用なCopを実装できたら、RuboCopにプルリクを投げると良いです。

  • RuboCopのメンバーがコードレビューしてくれる
  • 世界中のRuboCopユーザーがIssue挙げたり、メンテしてくれる

などのメリットがあります。

普段からRuboCopを便利に使わせて頂いているので、たまには恩返しとしてプルリクを送りたいですね。

おわり

「人間が何度もレビューで指摘する」は真面目で良いことだとは思いますが、怠惰力が足りないです。

機械的にレビューできる箇所は機械に任せ、我々は人間にしかできない「自動化の難しいところのレビュー」や「ビールを飲みながらアニメを観る」などを頑張りたい。

ActiveRecord の where.not とド・モルガンの法則

where.not を使っていて遭遇した問題についてrailsdmの懇親会で話していたら、@kamipo さんから「5.2では少し直っている」という情報を頂いた。

この話をしているときに思ったけど、今の where.not の問題点について知らない人も多そうなので、せっかくなのでブログにまとめてみた。

where.notの問題

where.not のドキュメントにいくつか例がある。

問題は複数の条件を引数で指定した場合。

User.where.not(name: "Jon", role: "admin")
# SELECT * FROM users WHERE name != 'Jon' AND role != 'admin'

これはド・モルガンの法則でORになりそうなのに、なっていない。

# ド・モルガンの法則
# NOT(A AND B) = NOT(A) OR NOT(B)

# つまり、これは
!(name == "Jon" && role == "admin")

# こうなる
name != "Jon" || role != "admin"

実際に遭遇したケース

ポリモーフィック関連を使っていて、 where.not と組み合わせた時に遭遇した。

# $ rails g model Picture imageable:references{polymorphic}
# $ rails g model Employee
# $ rails db:migrate:reset
# $ rails c
# Loading development environment (Rails 5.1.5)

e = Employee.create
#=> #<Employee id: 1, created_at: "2018-03-26 09:50:28", updated_at: "2018-03-26 09:50:28">
Picture.where.not(imageable: e).to_sql
#=> SELECT "pictures".* FROM "pictures" WHERE ("pictures"."imageable_type" != 'Employee') AND ("pictures"."imageable_id" != 1)

5.2.0.rc2 の挙動

5.2.0.rc2 ではポリモーフィック関連の挙動が直っている。

# $ rails g model Picture imageable:references{polymorphic}
# $ rails g model Employee
# $ rails db:migrate:reset
# $ rails c
# Loading development environment (Rails 5.2.0.rc2)

e = Employee.create
#=> #<Employee id: 1, created_at: "2018-03-26 09:56:18", updated_at: "2018-03-26 09:56:18">
Picture.where.not(imageable: e).to_sql
#=> SELECT "pictures".* FROM "pictures" WHERE NOT ("pictures"."imageable_type" = 'Employee' AND "pictures"."imageable_id" = 1)

そして、これが @kamipo さんの最高のコミット。

github.com

213796f のコミットでポリモーフィック関連の挙動が直った後、すかさずテストを追加している。*1

ポリモーフィック関連以外のケース

Issue は作られていて、議論されている。

github.com

個人的には 6.0 で複数条件のケースも直っていると嬉しい。

まとめ

where.not で ポリモーフィック関連複数の条件 を渡すコードがあったら注意しましょう。

*1:テストのある機能は基本的に維持される