2022/06/30 今回の気になった bugs.ruby のチケット

今週は Module#autoload で意図しない定数が定義された時に対応するチケットがありました。

[Feature #18815] instance_{eval,exec} vs Proc#>>

  • 次のように Proc#>> した結果は instance_eval / instance_exec に適さない
measure = proc { p "self=#{self}"; size }
multiply = proc { '*' * _1 }

# measure のブロック内のレシーバは 'test' になる
'test'.instance_eval(&measure)
# "self=test"
#  => 4

# しかし Proc#>> した場合はその限りではない
'test'.instance_eval(&measure >> multiply)
# "self=main"
# NameError (undefined local variable or method `size' for main:Object)
FLATTEN = -> { [*_1] }
IDENTITY = -> { _1 }
DATE = -> { Date.parse(_1) }

# パラメータのリストをどのように処理するのかの定義を持つ定数を定義したい
TRANSFORMATIONS = {
  param1: FLATTEN,
  param2: FLATTEN,
  param3: IDENTITY,
  # この時に他の処理を呼び出しつつ、コンテキストに依存するような処理を定義したい
  # param4: FLATTEN >> -> { allowed?(:something) ? _1 : DEFAULT }
  # 現状だと以下のように定義する必要がある
  param4: -> { allowed?(:something) ? FLATTEN.(_1) : DEFAULT }
}

[Bug #18813] Let Module#autoload be strict about the autoloaded constant

# /tmp/x.rb
module M
  class X
  end
end
# sample.rb
module M
  # M::X を参照した時に自動的に require '/tmp/x' される
  autoload :X, '/tmp/x'
end

# このタイミングで require '/tmp/x' される
M::X
  • また、まだ読み込まれていない場合に Module.constantsModule.const_defined? を使用すると次のような結果が返ってくる
module M
  autoload :X, '/tmp/x'
end

# まだ M::X は定義されていないが定義されているかのように振る舞う
p M.constants(false)          # => [:X]
p M.const_defined?(:X, false) # => true
  • この時に /tmp/xM::X が定義されていない場合に意図しない動作になる
# /tmp/x.rb

# M の配下ではなくてトップレベルに X が定義される
class X
end
# sample.rb
module M
  autoload :X, '/tmp/x'
end

p M.constants(false)          # => [:X]
p M.const_defined?(:X, false) # => true

module M
  # このタイミングで require '/tmp/x' される
  # しかし、実際には M::X は定義されない
  X
end

# それにより結果が変わる
p M.constants(false)          # => []
p M.const_defined?(:X, false) # => false
  • このように autoload するときの構造と実際に require された結果が違う場合はエラーにしたいというのがこのチケットの趣旨になる
  • 今回の対応では Ruby 3.2 では警告を出す対応になっている
# /tmp/x.rb

# M の配下ではなくてトップレベルに X が定義される
class X
end
# sample.rb
# -W を付けた時のみ警告が出る
pp $VERBOSE   # => true

module M
  autoload :X, '/tmp/x'
end

module M
  X
  # => /tmp/voLY8oW/52:10: warning: Expected /tmp/x to define M::X but it didn't
end

2022/06/23 今回の気になった bugs.ruby のチケット

今週はトップレベルで include したあとにクラス定義すると予期しないクラスを再オープンするというバグ報告がありました。

[Feature #18832] Do not have class/module keywords consider ancestors of Object

  • 次のように include M した後に C < String を定義すると意図しないエラーになるバグ報告
    • M::C とトップレベルの C は別のクラスとして扱われることを期待する
module M
  class C
  end
end

include M

# C は定義されていない
p Object.const_defined?(:C, false)
# => false

# M::C を再オープンしようとしていてエラーになっている
# error: superclass mismatch for class C (TypeError)
class C < String # (1)
end
  • これは include M すると C を参照する時に M::C を参照するようになっているからぽい
module M
  class C
  end
end

include M

# これは明示的に親スコープがない C を参照するので false
p Object.const_defined?(:C, false)

# これは C を探索しようとして M::C を見つけるのでそれを参照する
p C
# => M::C

# この C も M::C を参照する
class C < String # (1)
end
  • ちなみに以下のようにトップレベル以外だとエラーにはならない
module M
  class C
  end
end

module N
  include M

  # これは M::C を参照する
  pp C   # = > M::C

  # これは N::C を参照する
  class C < String
    pp self   # => N::C
  end

  # これは N::C を参照する
  pp C   # = > N::C
end
require "active_record"
require "rexml"

include REXML

class Comment < ActiveRecord::Base # superclass mismatch for class Comment (TypeError)
end

2022/06/17 今回の気になった bugs.ruby のチケット

[Bug #18826] Symbol#to_proc inconsistent, sometimes calls private methods

  • #tap& 渡しでメソッドを呼び出す場合に privateprotected メソッドを呼び出す事ができるバグ報告
class Test
  protected

  def referenced_columns
    puts "hello"
  end
end

# protected メソッドを呼び出す事ができる
Test.new.tap(&:referenced_columns)
# => hello

# Symbol#to_proc 経由でも呼び出せる
:referenced_columns.to_proc.call Test.new
# => hello

[Bug #18827] __ENCODING__ is not set to the source encoding when saving script lines

p __ENCODING__
# => #<Encoding:UTF-8>
# encoding: euc-jp
p __ENCODING__
# => #<Encoding:EUC-JP>
# -Ke を渡すと #<Encoding:EUC-JP> になる
$ ruby -Ke -e 'p __ENCODING__'
#<Encoding:EUC-JP>

$ cat script_lines.rb
SCRIPT_LINES__ = {}

# -Ke を渡すと #<Encoding:EUC-JP> になるがそうでない
$ ruby -r./script_lines.rb -Ke -e 'p __ENCODING__'
#<Encoding:UTF-8>

2022/06/11 今回の気になった bugs.ruby のチケット

今週は標準ライブラリをパターンマッチに対応させる提案がありました。

[Feature #18821] Expose Pattern Matching interfaces in core classes

  • Ruby のいくつかの標準ライブラリをパターンマッチに対応させるチケット
  • 提案されているのは以下の通り

Set

  • Array のようなパターンマッチを行う
# Hypothetical implementation
class Set
  alias_method :deconstruct, :to_a
end

Set[1, 2, 3] in [1, 2, *]
# => true

Matrix

  • Array のようなパターンマッチを行う
class Matrix
  alias_method :deconstruct, :to_a
end
# => :deconstruct

Matrix[[25, 93], [-1, 66]] in [[20..30, _], [..0, _]]
# => true

CSV

  • ヘッダーカラムをキーにして Hash のようなパターンマッチを行う
require "csv"
require "net/http"
require "json"

# Hypothetical implementation
class CSV::Row
  def deconstruct_keys(keys)
    # Symbol/String is contentious, yes, I will address in a moment
    self.to_h.transform_keys(&:to_sym)
  end
end

# Creating some sample data for example:
json_data = URI("https://jsonplaceholder.typicode.com/todos")
  .then { Net::HTTP.get(_1) }
  .then { JSON.parse(_1, symbolize_names: true) }

headers = json_data.first.keys
rows = json_data.map(&:values)

# Yes yes, hacky
csv_data = CSV.generate do |csv|
  csv << headers
  rows.each { csv << _1 }
end.then { CSV.parse(_1, headers: true) }

# But can provide very interesting results:
csv_data.select { _1 in userId: "1", completed: "true" }.size
# => 11

RegexpMatchData

  • 名前付きキャプチャに対して Hash のようなパターンマッチを行う
class MatchData
  alias_method :deconstruct, :to_a

  def deconstruct_keys(keys)
    named_captures.transform_keys(&:to_sym).slice(*keys)
  end
end

IP_REGEX = /
  (?<first_octet>\d{1,3})\.
  (?<second_octet>\d{1,3})\.
  (?<third_octet>\d{1,3})\.
  (?<fourth_octet>\d{1,3})
/x

'192.168.1.1'.match(IP_REGEX) in {
  first_octet: '198',
  fourth_octet: '1'
}
# => true

[Feature #18183] make SecureRandom.choose public

  • SecureRandom.choose という private メソッドを public メソッドにするチケット
require "securerandom"

# 第一引数の配列の中からランダムで10文字を返す
pp SecureRandom.send(:choose, ['a', 'b', 'c'], 10)
# => "cabbbaaaab"

pp SecureRandom.send(:choose, [*'A'..'Z', *'0'..'9'], 10)
# => "7JZMZUK4GD"
def alphanumeric(n=nil, alphabet: ALPHANUMERIC)
  n = 16 if n.nil?
  choose(alphabet, n)
end

[Feature #11689] Add methods allow us to get visibility from Method and UnboundMethod object.

  • Ruby 3.1 で {Method,UnboundMethod}#{public?,private?,protected?} が新しく追加された
class X
  private def private_method
  end

  public def public_method
  end
end

# 該当する可視性であれば true を返す
pp X.instance_method(:private_method).private?   # => true
pp X.instance_method(:private_method).public?    # => false
pp X.instance_method(:public_method).private?    # => false
pp X.instance_method(:public_method).public?     # => true
  • 最近この実装に対して matz がコメントしており Ruby 3.2 では Revert される流れになっている
    • https://bugs.ruby-lang.org/issues/11689#note-24
    • このチケットでは可視性はメソッドの属性という前提だったが、実際には各クラスで可視性ごとのメソッドのリストが必要とのこと
      • ##18435 を調べていて気づいたとのこと
      • そうしないと #18729#18751 のような問題が発生する
  • 参照しているメソッドは同じでもクラスによって可視性が変わる、ってことなんですかねー

[Bug #18790] cannot load such file -- digest (LoadError)

  • 特定の環境で CRuby をビルドすると require': cannot load such file -- digest (LoadError) になってビルドに失敗するので対処するチケット
  • 地味に困っているので何かしら本体で対応されるとうれしいなあ

2022/06/02 今回の気になった bugs.ruby のチケット

今週は除算の切り上げを行う Numeric#ceildiv を追加する提案がありました。

[Feature #18809] Add Numeric#ceildiv

  • 「除算の切り上げ」を実現する Numeric#ceildiv を追加する提案
    • 「除算の切り上げ」とは、最も近い整数に切り上げられる除算の商を取得すること
class Integer
  # notice that b > 0 is assumed
  def ceildiv(b)
    (self + b - 1) / b
  end
end

# 例えば123アイテムあります。各ページに10個のアイテムを表示すると、何ページありますか?
p 123.ceildiv(10) # => 13
  • これはあると便利かも

2022/05/26 今回の気になった bugs.ruby のチケット

今週は Refinements で protected されたメソッドが呼び出せないバグ報告がありました。

[Bug #18806] protected methods defined by refinements can't be called

  • Refinements で定義された protected がメソッドが呼び出せないというバグ報告
class A
end

module MyRefine
  refine A do
    private def private_foo
      "refined"
    end

    def private_foo_in_refinement
      private_foo
    end

    protected def protected_foo
      "refined"
    end

    def protected_foo_in_refinement
      # この呼び出しがエラーになる
      protected_foo
    end
  end
end

class A
  using MyRefine

  def call_private
    private_foo
  end

  def call_private_through_refinement
    private_foo_in_refinement
  end

  def call_protected
    # この呼び出しがエラーになる
    protected_foo
  end

  def call_protected_through_refinement
    protected_foo_in_refinement
  end

  def is_defined
    # defined? だとメソッドが定義されているように振る舞う
    defined?(protected_foo)
  end
end

pp A.new.call_private
# => :refined

pp A.new.call_private_through_refinement
# => :refined

pp A.new.call_protected
# => NoMethodError: protected method `protected_foo' called for #<A:0x00007f23f35e9390>

pp A.new.call_protected_through_refinement
# => NoMethodError: protected method `protected_foo' called for #<A:0x00007f23f35e9390>

pp A.new.is_defined
# "method"
  • 久々に Refinement のバグみたな?
    • これ、今まで見つかってなかったんだ…
  • protected でも問題なく protected のメソッドとして呼び出せるのが期待する挙動な気がする

[Bug #18793] Select and Find behave differently for hashes

  • 以下のように Hash#selectHash#find で挙動が違うというバグ報告
# キーにマッチした結果が返ってくる
{ 1..10 => :a, 11 .. 20 => :b }.select { _1 === 12 }
# => {11..20=>:b}

# しかし find の場合は見つからない
{ 1..10 => :a, 11 .. 20 => :b }.find { _1 === 12 }
# => nil

# select の _1 はキーを受け取る
{ 1..10 => :a, 11 .. 20 => :b }.select { p _1 }
# => 1..10
#    11..20

# find は [キー, 要素] の配列を受け取る
{ 1..10 => :a, 11 .. 20 => :b }.find { p _1 }
# => [1..10, :a]
  • _1 ではなくて { |k,| } のように , を付けてブロックの引数を受け取ると配列の第一要素のみを受け取るので両方共同じ挙動になる
# キーにマッチした結果が返ってくる
p({ 1..10 => :a, 11 .. 20 => :b }.select { |k,| k === 12 })
# => {11..20=>:b}

# しかし find の場合は見つからない
p({ 1..10 => :a, 11 .. 20 => :b }.find { |k,| k === 12 })
# => [11..20, :b]

# select の _1 はキーを受け取る
{ 1..10 => :a, 11 .. 20 => :b }.select { |k,| p k }
# => 1..10
#    11..20

# find は [キー, 要素] の配列を受け取る
{ 1..10 => :a, 11 .. 20 => :b }.find { |k,| p k }
# => 1..10

[Bug #18771] IO.foreach/.readlines ignores the 4th positional argument

  • IO.readlines の引数シグネチャは以下のようになっている
readlines(name, sep, limit [, getline_args, open_args]) → array
  • 位置引数を3つ受け取って、残りはキーワード引数で受け取るが、位置引数を4つ渡してもエラーにならないというバグ報告
    • ArgumentError になるのが期待する挙動
File.readlines('file.txt', "\n", 10)
# => ["abc\n", "\n", "def\n"]
File.readlines('file.txt', "\n", 10, {})
# => ["abc\n", "\n", "def\n"]
File.readlines('file.txt', "\n", 10, {chomp: true})
# => ["abc\n", "\n", "def\n"]
File.readlines('file.txt', "\n", 10, false)
# => ["abc\n", "\n", "def\n"]
File.readlines('file.txt', "\n", 10, nil)
# => ["abc\n", "\n", "def\n"]
readlines(path, rs = $/, chomp: false, opts={}) -> [String]
readlines(path, limit, chomp: false, opts={}) -> [String]
readlines(path, rs, limit, chomp: false, opts={}) -> [String]
readlines(name, sep=$/ [, getline_args, open_args]) → array
readlines(name, limit [, getline_args, open_args]) → array
readlines(name, sep, limit [, getline_args, open_args]) → array
readlines(name, sep=$/ [, getline_args, open_args]) → array
readlines(name, limit [, getline_args, open_args]) → array
readlines(name, sep, limit [, getline_args, open_args]) → array

[Bug #18797] Third argument to Regexp.new is a bit broken

# OK
Regexp.new('abc', Regexp::NOENCODING)

# error: /.../n has a non escaped non ASCII character in non ASCII-8BIT script: /あああ/ (RegexpError)
Regexp.new('あああ', Regexp::NOENCODING)
re = Regexp.new('あああ', nil, 'n') # => /あああ/
pp re.options.anybits? Regexp::NOENCODING   # => true

pp re.encoding   # => #<Encoding:ASCII-8BIT>
pp re.source.encoding   # => #<Encoding:UTF-8>

# error: incompatible encoding regexp match (ASCII-8BIT regexp with UTF-8 string) (Encoding::CompatibilityError)
pp re =~ "あああ"
  • Regexp.new の引数でどう制御できるのか軽く調べてみたけどあんまりよくわからなかった…

2022/05/20 今回の気になった bugs.ruby のチケット

今週はパターンマッチの #deconstruct を拡張する提案がありました。

[Feature #18788] Support passing Regexp options as String to Regexp.new

  • Regexp.new の第二引数にオプションを渡すことができる
Regexp.new('foo', Regexp::IGNORECASE | Regexp::MULTILINE | Regexp::EXTENDED) # => /foo/imx
  • これを文字列を渡せるようにする提案
Regexp.new('foo', 'i')   # => /foo/i
Regexp.new('foo', :i)    # => /foo/i

Regexp.new('foo', 'imx') # => /foo/imx
Regexp.new('foo', :imx)  # => /foo/imx
Regexp.new('foo', Regexp::IGNORECASE)   # => /foo/i

# これも i になる
Regexp.new('foo', "hoge")   # => /foo/i

# i を文字列で渡せるように見えるが
Regexp.new('foo', "i")   # => /foo/i

# これも i になる
Regexp.new('foo', "m")   # => /foo/i

[Feature #14602] Version of dig that raises error if a key is not present

  • 以下のように要素が見つからなかった場合に例外が発生する #dig! を追加する提案
hash = {
    :name => {
        :first => "Ariel",
        :last => "Caplan"
    }
}

hash.dig!(:name, :first) # => Ariel
hash.dig!(:name, :middle) # raises KeyError (key not found: :middle)
hash.dig!(:name, :first, :foo) # raises TypeError (String does not have #dig! method)
  • #[] でも似たような事は実現できるがより自明にしたいのが目的
hash = {
    :name => {
        :first => "Ariel",
        :last => "Caplan"
    }
}

hash[:name][:first] # => Ariel
hash[:name][:middle] # => nil
hash[:name][:first][:foo] # => `[]': no implicit conversion of Symbol into Integer (TypeError)

[Feature #18774] Add Queue#pop(timeout:)

[Feature #18773] deconstruct to receive a range

  • #deconstruct_keys を定義する事でパターンマッチで { a:, b: } パターンを任意のオブジェクトで使うことができる
    • 引数に Hash のキーを受け取る事ができる
class Time
  # Hash パターンのキーを受け取る
  # in { year:, day: } なら [:year, :day]
  def deconstruct_keys(keys)
    # キーを元にしてパターンマッチに必要な Hash を返す
    keys.to_h { [_1, send(_1)] }
  end
end

time = Time.new(2020, 1, 1)
pp time

# time.year と time.day を受け取る事ができる
case time
in { year:, day: }
  pp year
  pp day
end
  • 同様に [a, b] の場合は #deconstruct で拡張できる
  • この引数に [] の個数を Range で受け取る提案
class DeconstructWithRange
  def initialize(values)
    @values = values
  end

  # range で in の配列の個数を 個数..個数 で受け取る
  # * がふくまれている場合は 個数..無限 になる
  def deconstruct(range)
    range.cover?(@values.length) ? @values : []
  end
end

case DeconstructWithRange.new([1, 2])
# deconstruct(2..2) を呼び出す
in ["hoge", "foo"]
  true
# deconstruct(3..3) を呼び出す
in ["hoge", "foo", "bar"]
  true
# deconstruct(2..) を呼び出す
in ["hoge", "foo", "bar", *]
  true
else
  true
end
class ActiveRecord::Relation
  def deconstruct(range)
    # 配列の個数が一致している時のみ record を読み込んでくる
    (loaded? || range.cover?(count)) ? records : nil
  end
end

case Person.all
in []
  "No records"
# Person.all のレコード数が1個の時のみレコードを読み込んで処理する
in [person]
  "Only #{person.name}"
else
  "Multiple people"
end
  • あれば便利そうな気がするけどわざわざ Range で受け取らなくてもよい気がするなあ
    • 個数 + * があるかどうか、の2つの情報を受け取るほうが意図は伝わりやすそう?
class ActiveRecord::Relation
  # 個数と * があるかどうかを受け取る
  def deconstruct(count, rest:)
    # 配列の個数が一致している時のみ record を読み込んでくる
    (loaded? || self.count == count) ? records : nil
  end
end