Railsで、二重否定(!!) + ぼっち演算子(&.) が使われているソースコードの挙動を確認してみた

Railsを使ったソースコードを眺めていたところ、モデルのメソッドにて

def foo
  !!bar&.baz
end

というコードがありました。

どのような挙動になるのか気になったので、試してみた時のメモを残します。

 
目次

 

環境

 

モデル構造

今回は3つのモデル

があるとします。

また、それぞれの関連は

  • Market : Area = n : 1
  • Area : Apple = 1: n

となっています。

各モデルの定義は以下の通りであり、今回挙動を知りたいメソッド sell_apple?Market モデルにあるとします。

なお、今回は各関連先が無いケースについても動作確認をするため、 belongs_tooptional: true をつけています。
4.1.2.11 :optional | Active Record の関連付け - Railsガイド

# Market
class Market < ApplicationRecord
  # optional: trueにすることで #<ActiveRecord::RecordInvalid "Validation failed: Area must exist"> を防ぐ
  # 今回は area が無い場合の動作確認もしているので、optional: true の設定が必要
  belongs_to :area, optional: true

  def sell_apple?
    !!area&.apples
  end
end


# Area
class Area < ApplicationRecord
  has_many :apples
end


# Apple
class Apple < ApplicationRecord
  belongs_to :area, optional: true
end

 
ER図的にはこんな感じです。

 

挙動確認

sell_apple? メソッドの挙動を確認する前に、気になる部分の挙動を確認していきます。

ちなみに、今回はテストコードを書いて挙動を確認してみます。

なお、記事では必要な部分だけ抜粋しています。ソースコード全体は以下で確認できます。
https://github.com/thinkAmi-sandbox/rails_7_0_minimal_app/pull/6

 

否定(!)や二重否定(!!)の確認

まずは否定(!)や二重否定(!!)の確認をします。

 
動作を確認してみると、インスタンスの値をbooleanに変換しています。

context '!や!!の確認' do
  let!(:market) { create(:market, area: nil) }

  context '取得しただけ' do
    # itの中身がわかりやすいので、itの説明は省略(以降同様)
    it do
      actual = Market.find(market.id)

      expect(actual).to eq(market)
    end
  end

  context '!(否定)' do
  it do
    actual = Market.find(market.id)

    expect(!actual).to eq(false)
  end
end

context '!!(二重否定)' do
  it do
    actual = Market.find(market.id)

    expect(!!actual).to eq(true)
  end
end

 

ぼっち演算子(&.)と組み合わせた、!や!!の確認

続いて、ぼっち演算子と組み合わせた時の挙動を確認します。

 

多対一での確認(market : area = n : 1)

まずは、多対一の確認をします。

 

一側(area)が無い場合

一側が無い場合、ぼっち演算子を使っているので nil になります。
Rubyで使われる記号の意味(正規表現の複雑な記号は除く) (Ruby 3.2 リファレンスマニュアル)

context 'marketのみあり' do
  let!(:market) { create(:market, area: nil) }

  context '&.' do
    it do
      actual = Market.find(market.id)

      expect(actual&.area).to eq(nil)
    end
  end

 
そのため、 nil の否定では true 、二重否定では false になります。

context '!&.' do
  it do
    actual = Market.find(market.id)

    expect(!actual&.area).to eq(true)
  end
end

context '!!&.' do
  it do
    actual = Market.find(market.id)

    expect(!!actual&.area).to eq(false)
  end
end

 

一側(area)がある場合

一側がある場合は、一側が無いときと逆の結果になります。

context 'marketとareaともにあり' do
  let!(:area) { create(:area) }
  let!(:market) { create(:market, area: area) }

  context '&.' do
    it do
      actual = Market.find(market.id)

      expect(actual&.area).to eq(area)
    end
  end

  context '!&.' do
    it do
      actual = Market.find(market.id)

      expect(!actual&.area).to eq(false)
    end
  end

  context '!!&.' do
    it do
      actual = Market.find(market.id)

      expect(!!actual&.area).to eq(true)
    end
  end
end

 

一対多での確認(area : apple = 1 : n)

続いて、一対多の確認をします。

 

多側(apple)が無い場合

多側がない場合、 actual&.apples の結果は #<ActiveRecord::Relation []> になります。

let!(:area) { create(:area) }

context '&.' do
  it do
    actual = Area.find(area.id)

    # 左辺: #<ActiveRecord::Relation []>
    expect(actual&.apples).to eq([])
  end
end

 
続いて否定(!)を見てみます。

この場合、 [] の否定形のbooleanを取得することになるので、 false になります。

これは、Rubyfalse

false は FalseClass クラスの唯一のインスタンスです。 false は nil オブジェクトとともに偽を表し、その他の全てのオブジェクトは真です。

class FalseClass (Ruby 3.2 リファレンスマニュアル)

なことから、 [] は真であり、 ![] は偽であるためです。

context '!&.' do
  it do
    actual = Area.find(area.id)

    expect(!actual&.apples).to eq(false)
  end
end

 
二重否定(!!)の場合は、否定の更に逆なので、元に戻って真になります。

context '!!&.' do
  it do
    actual = Area.find(area.id)

    expect(!!actual&.apples).to eq(true)
  end
end

 

多側(apple)がある場合

多側がない場合の逆の結果となります。

let!(:area) { create(:area) }
let!(:apple) { create(:apple, area: area)}

context '&.' do
  it do
    actual = Area.find(area.id)

    expect(actual&.apples).to eq([apple])
  end
end

context '!&.' do
  it do
    actual = Area.find(area.id)

    expect(!actual&.apples).to eq(false)
  end
end

context '!!&.' do
  it do
    actual = Area.find(area.id)

    expect(!!actual&.apples).to eq(true)
  end
end

 

元々のメソッドの動作確認

気になる動作

改めて、今回のきっかけとなったメソッドと同じ作りのメソッド sell_apple? を見てみます。

class Market < ApplicationRecord
  def sell_apple?
    !!area&.apples
  end
end

 
このメソッドは、関連先の関連先(apple)があれば true を返してほしいように見えます。

ただ、ここまで見てきた通り、多側である apple が存在しないときにも true を返してしまい、意図しない挙動となっています。

context 'Marketのみあり' do
  let!(:actual) { create(:market, area: nil) }

  it do
    expect(actual.sell_apple?).to eq(false)
  end
end

context 'MarketとAreaあり' do
  context 'area without apple' do
    let!(:area) { create(:area) }
    let!(:actual) { create(:market, area: area) }

    it do
      expect(actual.sell_apple?).to eq(true) # << ここ
    end
  end
end 

context 'MarketとAreaとAppleあり' do
  let!(:area) { create(:area) }
  let!(:apple) { create(:apple, area: area)}
  let!(:actual) { create(:market, area: area) }

  it do
    expect(actual.sell_apple?).to eq(true)
  end
end

 

present?を使って解消

多側が存在しないときも false を返してほしい場合は、RailsではActiveSupportpresent? メソッドが使えそうです。
2.1 blank?とpresent? | Active Support コア拡張機能 - Railsガイド

 
そこで、 sell_apple_with_present? メソッドを定義し、 sell_apple? メソッドと挙動を比較してみます。

class Market < ApplicationRecord
  belongs_to :area, optional: true

  def sell_apple?
    !!area&.apples
  end

  # 追加
  def sell_apple_with_present?
    area&.apples.present?
  end
end

 
結果を確認すると、 sell_apple_with_present? メソッドは、多側が無い場合でも false を返すようになりました。良さそうな挙動です。

context 'Marketのみあり' do
  let!(:actual) { create(:market, area: nil) }

  it do
    expect(actual.sell_apple?).to eq(false)
    expect(actual.sell_apple_with_present?).to eq(false)
  end
end

context 'MarketとAreaあり' do
  context 'area without apple' do
    let!(:area) { create(:area) }
    let!(:actual) { create(:market, area: area) }

    it do
      # ここだけ結果が違う
      expect(actual.sell_apple?).to eq(true)
      expect(actual.sell_apple_with_present?).to eq(false)
    end
  end
end

context 'MarketとAreaとAppleあり' do
  let!(:area) { create(:area) }
  let!(:apple) { create(:apple, area: area)}
  let!(:actual) { create(:market, area: area) }

  it do
    expect(actual.sell_apple?).to eq(true)
    expect(actual.sell_apple_with_present?).to eq(true)
  end
end

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_7_0_minimal_app

今回のプルリクはこちらです。
https://github.com/thinkAmi-sandbox/rails_7_0_minimal_app/pull/6