Ruby の Hash リテラルでキーが重複している場合の奇妙な動作

最近こういう動作を見つけたので覚書。

Hash リテラルでキーが重複しているとどうなるのか

Hash リテラルで Hash を定義した場合、キーの順番は『記述した順番』になります。

# ここで定義した順番になる
hash = { age: 14, name: "homu" }
pp hash
# => {:age=>14, :name=>"homu"}

hash = { name: "homu", age: 14 }
pp hash
# => {:name=>"homu", :age=>14}

では、この Hash リテラルでキーが重複しているとどうなるのかというと次のようになります。

# age が重複している場合
hash = { id: 1, age: 14, name: "homu", age: 20, age: 10 }
# キーの順番は age が先にくるが、値は最後に定義した値になる
pp hash
# => {:id=>1, :age=>10, :name=>"homu"}

こんな感じで

  • キーの位置は『最初に定義された位置』
  • キーの値は『最後に定義された値』

という挙動になります。
キーの位置と値が異なるのでちょっと混乱しますね。
このような書き方をすると警告もでますし、基本的に Hash リテラルではキーを重複して書かないようにしましょう。

# warning: key :age is duplicated and overwritten on line 2
hash = { id: 1, age: 14, name: "homu", age: 20, age: 10 }

関連

2021/03/11 今週の気になった bugs.ruby のチケット

内容は適当です。
今週と言っても今週みかけたチケットなだけでチケット自体は昔からあるやつもあります。
あくまでも『わたしが気になったチケット』で全ての bugs.ruby のチケットを載せているわけではありません。

[Bug #16908] Strange behaviour of Hash#shift when used with default_proc.

  • 以下のように default_proc が設定されている状態で Hash#shift を呼ぶといと意図しない値が返ってくるよっていうチケット
    • Hash が空の時に shift を呼ぶと default_proc の値が返ってくる
hash = Hash.new{|k,v| k[v] = 0}

hash.shift # => 0
hash.shift # => [nil, 0]
  • 意図としては両方共 [nil, 0] が返ってきてほしい
  • もうちょっと詳細に説明するとこんな感じ
hash = Hash.new{|k,v| k[v] = 0}

# 空
p hash       # => {}

# ここは default_proc 値を返す
# ここが意図していないというチケット
p hash.shift # => 0

# hash,shift 後は中身が入ってる状態になる
p hash       # => {nil=>0}
# ので、これは [nil, 0] を返す
p hash.shift # => [nil, 0]
  • この挙動は確かに奇妙
  • チケットだと nil を返すほうがいい、みたいな意見もある

[Feature #17674] Proposal: Method#source_location or Method#owner for refined methods

# test.rb
module Cover
  refine Range do
    def cover?(value)
      return super unless value.is_a?(Range)

      super(value.first) && super(value.last)
    end
  end
end
using Cover

pp (1..10).method(:cover?).source_location
# Ruby 2.6 => nil
# Ruby 2.7 => ["/test.rb", 4]

pp (1..10).method(:cover?).owner
# Ruby 2.6 => Range
# Ruby 2.7 => #<refinement:Range@Cover>

2021/03/05 今週の気になった bugs.ruby のチケット

内容は適当です。
今週と言っても今週みかけたチケットなだけでチケット自体は昔からあるやつもあります。
あくまでも『わたしが気になったチケット』で全ての bugs.ruby のチケットを載せているわけではありません。

[Feature #17660] Expose information about which basic methods have been redefined

  • RubyVM.redefined_methods というメソッドを追加する提案
  • これは再定義されてた標準的なメソッドの一覧を返すようなメソッドになる
class Integer
  def +(x); x ** self; end
end

# 再定義された Integer#+ メソッドの情報を返す
p RubyVM.redefined_methods # => {Integer=>[:+]}
  • これによって再定義されてしまうことを防ぐことができるらしい
Minitest.after_run {
  fail "Basic methods have been redefine" if RubyVM.redefined_methods.any?
}

[Bug #17652] GC compaction crash on mprotect

  • GC compaction でクラッシュするというバグ報告
  • 以下のコードで再現
GC.auto_compact = true

times = 20_000_000
arr = Array.new(times)
times.times do |i|
  arr[i] = "#{i}"
end

arr = Array.new(1_000_000, 42)
GC.start

puts "ok"
  • こんなコードで再現するんだ… GC なんもわからねえ…

[Feature #17663] Enumerator#with, an alternative to Enumerator#with_object

  • Enumerator#with を追加する提案
  • #with に渡した引数を #with のブロックの引数として受け取る
    • Enumerator#with と似ているが戻り値がちょっと違う
class Enumerator
  def with(*options)
    return to_enum(:with, *options) unless defined? yield

    each do |entry|
      yield entry, *options
    end
  end
end


# each_with_object は each_with_object の引数を返す
pp (1..5).map.each_with_object(2) { |it, n| p it.to_s(n) }
# => 2

# with はそのままイテレーションの結果を返す
pp (1..5).map.with(2) { |it, n| it.to_s(n) }
# => ["1", "10", "11", "100", "101"]

# こうもかける
pp (1..5).map.with(2, &:to_s)
# => ["1", "10", "11", "100", "101"]
  • #each_with_object#map などでチェーンしたい場合はあるのでこれは普通に便利そう
  • まあ今なら素直にナンパラを使えばいいじゃん、という気もするが…
    • (1..5).map { _1.to_s(2) }

[Misc #17662] The heredoc pattern used in tests does not syntax highlight correctly in many editors

assert_ruby_status([], "#{<<-"begin;"}\n#{<<-'end;'}", bug)
begin;
  exit("1" == Thread.start(1, &:to_s).value)
end;
  • これは以下のような文字列になる
str = "#{<<-"begin;"}\n#{<<-'end;'}"
begin;
  exit("1" == Thread.start(1, &:to_s).value)
end;
pp str
# => "\n" + "  exit(\"1\" == Thread.start(1, &:to_s).value)\n"
  • この書き方だとエディタでハイライトされないことが多いので以下のように書き直そう、という提案
assert_ruby_status([], <<~'RUBY', bug)
  exit("1" == Thread.start(1, &:to_s).value)
RUBY
  • 手元の Vim だとうまくハイライトされなかった…うーん…

[Bug #17661] IO#each will segfault when if file is closed inside an each_byte block

  • 以下のように File#each_byte 内で File#close すると segv するというバグ報告
file = File.open(__FILE__)
file.each_byte do |byte|
  file.close
end

[Bug #17667] Module#name needs synchronization

  • Module#name は非同期処理に対応していないので以下のようにすると segv するというバグ報告
class C
  @iv = 1
end
Ractor.new {
  loop {
    C.name
  }
}

class C
  0.step { |i|
    instance_variable_set("@iv#{i}", i)
  }
end
  • Module#name 内でインスタンス変数にアクセスしたりしているんですかね?普通に踏みそう

BuriKaigi2021 で Ruby 2.0 〜 Ruby 3.0 までの話をしてきた

はい、ということで開催されてからだいぶ時間が経っちゃったんですが先月末に開催された BuriKaigi2021Ruby 2.0 〜 Ruby 3.0 の話をしてきました。

Ruby 2.0 から Ruby 3.0 を駆け足で振り返る

と、ここでならいつも雑に振り返りを行うのですが以下のブログに影響されてもう少し掘り下げてまとめてみようかと思います。

登壇するきっかけ

元々はわたしがよく参加している勉強会に BuriKaigi の関係者の方が参加していることが多くて『今年はコロナで大変だけど BuriKaigi をやりたいよねー』っていう話を前からしてはいたんですよね。
わたし自身は BuriKaigi 自体参加したことがなかったんですが『どうせなら登壇してみたいなー』みたいには前々からちょっと思っていました。
で、12月の頭ぐらいに『来年 BuriKaigi やりますー』って話を聞いて『○○○みたいな話をしてみたいんですが登壇者の募集とかってどうなっています?』みたいな感じで前から温めていたネタで打診してみました。
その時はまだ確定ではなかったんですが 12月下旬ぐらいに GO サインがでて登壇することが確定しました。
まあ完全に勢いとコネですね。人生にだいじだいじ。

テーマ選定

わたしは主に LT の場で話すことが多いのですが、その時のテーマ選定はだいたい

  • わたしが話したい内容か
  • 参加者に刺さる内容か
  • 時間内で話せそうか

あたりを基準として決めることが多いです。
具体的に『何を話すのか』は直前まで悩んでいることが多くて LT を申し込む時点ではキマってないことの方がおおいです。
本当の意味で『その時に話したいこと』を話しています。そっちのほうがモチベーションが上がりますしねー。
ただし、今回の BuriKaigi2021 の場合は『最初にこういう内容で登壇したい』という形で打診をしたので『最初に話す内容を決めた』っていう点でいつもとはちょっと違っていました。珍しい。

具体的なテーマ選定

今回の BuriKaigi2021 では最初から以下のような基準で話したいなーと思っていました。

  • キャッチーな話をしたい
  • あんまり Ruby のコアな話はしない
    • BuriKaigi の参加者層的な意味で
  • けど Ruby をもっと知ってもらいたい!
  • せっかくなので Ruby 3.0 の話をしたいなあ…

特に『せっかくRuby 3.0 がリリースされる(された)のでその話もしつつ普段は Ruby を知らない人にも興味がある内容を話したいなー』という点を重点的に考えていましたね。
そんなことを考えている時に『そういやわたしって Ruby 2.5 から本格的に Ruby をはじめたけどそれ以前のバージョンはよくしらんなーなんか Ruby の軌跡みたいなの話せると面白いのかなー』と思って『あーー『Ruby 2.0 〜 Ruby 3.0 で Ruby がどう変わってきたのか』みたいなのを話すと面白そうかなーー』っていう感じでテーマが漠然と決まった感じですね。
とりあえず『Ruby 2.0 〜 Ruby 3.0 のリリースノートをまとめて話そ〜〜』みたいに雑に決めました。

テーマの深堀り

ざっくりと話すことを決めたのでここでは具体的にどんな内容を話すのか考えていきます。
わたしの場合は実際にスライドを書くまでにある程度話したい内容を箇条書きにして書き溜めて行くことが多いです。
なので今回も決定してから登壇の 1週間前まで手元で話したいことを以下のような感じで書き溜めておきました。

こんな感じですね。
登壇時間は 30分だったので各 Ruby のバージョンを 2分で話すと 2 x 9 で 18分になります。
残り 12分を穴埋めするために Ruby とはRuby 2.0 以前の歴史 みたいな話も追加して話すことにしました。
更にどうせなら Ruby 3.0 のことも少し話したいなーと思い以下のように追加しました。

うーん、ちょっと 30分だと心もとなくなってきました。
で、ここからは裏話なんですが最初は登壇時間が 30分だったんですがわたしの後の枠が空いていたので +15分してもらって合計45分の時間に延長してもらいました。 いえーい。
なので、更に話すことを追加して最終的には以下のようなアウトラインが完成しました。

  • 自己紹介
  • Ruby とは
  • Ruby 2.0 以前の歴史
  • Ruby 2.0 ~ 3.0 までを振り返る
  • 最近のモダンな機能紹介・デモ
    • パターンマッチ
    • 1行 in
    • 右代入
    • Ractor
    • 型周りの話
    • irb
  • まとめ

ポイントとしては irb のデモを最後に入れたことですね。
時間が余れば irb のデモを長くすればいいですし、時間が足りなければそこを短くすることで時間を調整することができると思っていました。
まあ実際はガバガバだったんですけどね…。
これを元にしてスライドを書いていきます。

スライドを作る

上記のアウトラインを元にしてスライドを書いていきます。
実際にスライド作成に取り掛かったのは登壇の1週間ぐらい前になります。

スライド作成ツール

スライド作成はいつも reveal.js で作っています。
markdown でシュッとスライドを書くことができて、なおかつミニマムな構成で完結しているのが気に入っています。
レイアウトとかも css などをいじれば調整できますしね。

スライド作成と練習

今回のスライドはちょっと量が多かったのでまずはかける項目から書いていきました。
Ruby 2.0 ~ 3.0 までを振り返る はひたすら当時のリリースノートを参考にして Ruby を知らない人でもわかるような機能をピックアップしてスライドに書いていきました。
逆にいえば Ruby を使っている人しかわからないような細かい変更はあんまり書かないように心がけていましたね。
だいたい各バージョンが 1〜2分ぐらいで収まるような量にしつつ、わかりやすいようになるべくサンプルコードも記述していきました。
書くのに一番苦労したのはやっぱり Ruby 2.0 あたりの話ですね。
最近の Ruby の動向はリアルタイムで追っているので経緯とか把握していることが多いのですが Ruby 2.0 の時代は全く知らないので調べるのに苦労しました。
あと単純に当時の資料とかを読んでると面白くてスライド書かないでずーっと資料を読み漁っていましたね…。
Ruby 2.0 以前の歴史 あたりの話も同様に昔話を聞いてるような感じで調べつつまとめていました。
最近のモダンな機能紹介・デモ に関しては最近いろんなところで話をしているので既存のスライドから拝借して書いたのでそこまで時間はかかりませんでした。
そんな感じで内容はひたすら書いていく感じで構成とか内容自体はそんなに変更せずに書いていきました。
スライド自体が完成したのは確か前日の夜ぐらいでわたしにしては早めに完成していましたね。
練習に関しては『喋り慣れてるからまあそこまで練習しなくても大丈夫やろガハハ』って感じでそこまでやっていませんね。
それを元にして以下のように 残り時間 経過時間 を書き留めて置きます。

  • 開始 (残り時間 45分) (経過時間 0分)
  • 自己紹介
  • Ruby とは
  • Ruby 2.0 以前の歴史 (37分) (8分)
  • Ruby 2.0 ~ 3.0 までを振り返る
  • 最近のモダンな機能紹介・デモ (20分) (25分)
    • パターンマッチ
    • 1行 in (15分) (30分)
    • 右代入
    • Ractor
    • 型周りの話
    • irb (5分) (40分)
  • まとめ

登壇中はこれを見ながら残り時間を確認していました。

発表前にやること

うちの環境は2画面なんですが登壇中に見る画面には、

  • スライド
  • タイマー
  • (必要であれば)エディタ

みたいに最低限必要な情報のみ見えるようにしています。
で、もう一方の画面には Twitter や Zoom のチャットなど聞いてる人の声がわかるような形にしています。
あとは水を用意したりとかですかねえ。
あ、あと見づらい人用にスライドは発表前に公開するようにしています。

発表中にやること

スライドを見ながら話しつつタイマーで残り時間を確認しつつ余裕があれば Twitter で意見をみつつって感じで登壇しています。
流石に最近はスライドを見ながらタイマーを見るのは慣れてきましたねえ。
喋り始めは緊張するんですが喋ってる最中はもう殆ど緊張する事はなくなっています。

発表後にやること

ひたすら Twitter を見て気になる発言には個別に反応するようにしています。
なんかそっちのほうが盛り上がっている感があるのとやっぱり何かしらのフィードバックがもらえると嬉しいのでそこはちゃんとこっちもフィードバックするようにしています。

発表を終えてみて

機会があれば BuriKaigi で話してみたいなーと思っていたのでそれが達成できて個人的にはかなり満足度が高かったです。
特にこの手の大きなカンファレンスはオフラインだとなかなか参加しづらいのでオンラインで登壇できる環境っていうのは個人的にはめちゃくちゃありがたいんですよねー。
また機会があれば参加してみたいです。
運営の方々、参加者の方々、ありがとうございましたー!!!

2021/02/25 今週の気になった bugs.ruby のチケット

内容は適当です。
今週と言っても今週みかけたチケットなだけでチケット自体は昔からあるやつもあります。
あくまでも『わたしが気になったチケット』で全ての bugs.ruby のチケットを載せているわけではありません。

[PR #4193] Add Hash#delete_at

  • Hash#delete_at を追加する提案
    • bugs.ruby にまだチケットはなさそう
  • 特定のキーの要素を取り除いて、取り除いた要素を返すメソッド
hash = { a: true, b: false, c: nil }
# hash から直接要素を取り除きつつ、取り除いた値を返す
a, c = hash.delete_at(:a, :c) # => [ true, nil ]
hash # => { b: false }

# Hash#except は取り除かれた結果を返す
hash = { a: true, b: false, c: nil }
hash = hash.except(:a, :c)
p hash
# {:b=>false}
  • 取り除いた要素を参照したい場合は便利そう

[PR #207] Support pattern matching in CSV rows

  • CSV::Row をパターンマッチに対応させる PR
    • CSV の行ごとにパターンマッチできる
  • 以下のようにヘッダーカラムがキーで要素が値になる
header = [:id, :name, :age]
case CSV::Row.new(header, [1, "homu", 14])
in name:, age:
  name # => "homu"
  age  # => 14
end
  • これは普通に便利そう
  • こういうパターンマッチ対応って今後流行っていくんですかねー

[Bug #17590] M.prepend M has hidden side effect

  • 先週話していたバグ
  • 以下のように M.prepend M を呼んだ場合に副作用あるバグが修正された
module M; end
class C; end

C.prepend M
C.include M

# これを読んだ時に失敗するが、副作用があった
M.prepend M rescue nil

module M2; end
M2.prepend M
C.include M2

p C.ancestors
# 3.0 => [M, C, M2, M, M2, Object, Kernel, BasicObject]
# 3.1 => [M, C, M2, Object, Kernel, BasicObject]

[Bug #17649] defined? invokes method once for each syntactic element around it

  • defined? の式で複数回メソッドが呼ばれることがあるというバグ報告
  • 以下の例だと x メソッドが defined? 時に複数回呼ばれることがある
public def x
  $times_called += 1
end

def times_called
  $times_called = 0
  yield
  $times_called
end

# without `defined?`
times_called { x }           # => 1
times_called { -x }          # => 1
times_called { --x }         # => 1
times_called { ---x }        # => 1
times_called { x+0+0 }       # => 1
times_called { x.pred.pred } # => 1
times_called { x.x.x.x.x.x } # => 6

# with `defined?`
times_called { defined? x }           # => 0
times_called { defined? -x }          # => 1
times_called { defined? --x }         # => 2
times_called { defined? ---x }        # => 3
times_called { defined? x+0+0 }       # => 2
times_called { defined? x.pred.pred } # => 2
times_called { defined? x.x.x.x.x.x } # => 15
  • これは defined? a.b.c.d を呼び出した時に a a.b a.b.c a.b.c.d が個別に呼び出されしまっているからのようです
puts RubyVM::InstructionSequence.new("defined? a.b.c.d").disasm
__END__
output:
== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(1,16)> (catch: TRUE)
== catch table
| catch type: rescue st: 0001 ed: 0039 sp: 0000 cont: 0041
| == disasm: #<ISeq:defined guard in <compiled>@<compiled>:0 (0,0)-(-1,-1)> (catch: FALSE)
| local table (size: 1, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
| [ 1] $!@0
| 0000 putnil
| 0001 leave
|------------------------------------------------------------------------
0000 putnil                                                           (   1)[Li]
0001 putself
0002 defined                                func, :a, false
0006 branchunless                           41
0008 putself
0009 opt_send_without_block                 <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0011 defined                                method, :b, false
0015 branchunless                           41
0017 putself
0018 opt_send_without_block                 <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0020 opt_send_without_block                 <calldata!mid:b, argc:0, ARGS_SIMPLE>
0022 defined                                method, :c, false
0026 branchunless                           41
0028 putself
0029 opt_send_without_block                 <calldata!mid:a, argc:0, FCALL|VCALL|ARGS_SIMPLE>
0031 opt_send_without_block                 <calldata!mid:b, argc:0, ARGS_SIMPLE>
0033 opt_send_without_block                 <calldata!mid:c, argc:0, ARGS_SIMPLE>
0035 defined                                method, :d, true
0039 swap
0040 pop
0041 leave

2021/02/18 今週の気になった bugs.ruby のチケット

内容は適当です。
今週と言っても今週みかけたチケットなだけでチケット自体は昔からあるやつもあります。
あくまでも『わたしが気になったチケット』で全ての bugs.ruby のチケットを載せているわけではありません。

[Misc #17591] Test frameworks and REPLs do not show deprecation warnings by default

  • テストフレームワークRSpec や test-unite)や REPL(irb とか)で非推奨の警告をデフォルトで出すようにしよう、というチケット
  • 既に test-unit 3.4.0 ではデフォルトで有効になっており RSpec でも同様のチケットが立てられている
  • また irb に関しては『初心者が使うと混乱する』という理由で同意はされていないようです
  • 個人的には Ruby 自体が『デフォルトでは非推奨の警告を出さないように決めた』ので基本的には(少なくとも組み込みの範囲では)デフォルトでは出さないようにしてほしい気持ちが強い
    • このケースは警告がでて、このケースは警告がでないみたいにすると一貫性もないし、ユーザは混乱するだけ
    • Ruby 2.7.2 の世界では個々で必要に応じて警告を出すようにすべき

[Bug #17590] M.prepend M has hidden side effect

  • M.prepend M を呼び出すとエラーになるが副作用があるというバグ報告
  • 以下のように M.prepend M を呼んだ場合とそうでない場合で差異がある
module M; end
class C; end
C.prepend M
C.include M

module M2; end
M2.prepend M
C.include M2

# M.prepend M を呼んでない場合
p C.ancestors # => [M, C, M2, Object, Kernel, BasicObject]


M.prepend M rescue nil
module M3; end
M3.prepend M
C.include M3

# M.prepend M を呼んだ場合
# M が複数追加されている…
p C.ancestors # => [M, C, M3, M, M3, M2, Object, Kernel, BasicObject]
  • これには2つの問題があるようで、1つは以下のように M.prepend M すると M.ancestors # => [M, M] となる問題
module M
end

# 継承リストは自身だけ
pp M.ancestors
# => [M]

begin
  # 自身を prepend するとエラーになる
  # これは Ruby 2.7 でも 3.0 でも同じ
  M.prepend M
rescue => e
  puts "error : #{e.message}"
  # => error : cyclic prepend detected
end

# M.prepend M はエラーになるが Ruby 3.0 では副作用がある
# 3.1-dev では修正済み
pp M.ancestors
# 2.7 => [M]
# 3.0 => [M, M]
# 3.1 => [M]

Ruby で ["homu", "mami", "mado"].sum を呼ぶとエラーになる

今日の知見です。
Array#sum メソッドは要素を #+ で結合してその結果を返します。

p [1, 3, 5, 7].sum
# => 16

また Array#sum に引数を渡すことで足し込む値の初期値を指定することもできます。

p [1, 3, 5, 7].sum(100)
# => 116

["homu", "mami", "mado"].sum を呼ぶとエラーになる

では要素が文字列の場合はどうでしょうか。
残念ながら要素が文字列の場合はエラーになってしまいます。

# error: `+': String can't be coerced into Integer (TypeError)
pp ["homu", "mami", "mado"].sum

これは Array#sum が内部で使用している初期値に問題があります。
Array#sum は足し込むときの初期値として 0 を使用しています。
なので最初に足し込む際に 0 + "homu" みたいな処理が実行されてしまいエラーになってしまいます。
これを回避する場合は Array#sum に明示的に初期値 "" を渡すことで回避することができます。

# OK
pp ["homu", "mami", "mado"].sum("")
# => "homumamimado"

# 任意の要素を初期値にもできる
pp ["homu", "mami", "mado"].sum("joined : ")
# => "joined : homumamimado"

なぜエラーになるのかの原因がわかっているならいいんですがいきなり String can't be coerced into Integer (TypeError) ってエラーになってもぎょっとしますよね。
ちなみに単に文字列を結合したい場合は Array#join が利用できます。

pp ["homu", "mami", "mado"].join
# => "homumamimado"