RubyのStructのイディオムとアンドキュメンテッドな機能

改めてStruct関連のイディオムについて調べた。Ruby標準のStructクラスを使えばコンストラクタでの面倒な初期化を飛ばせる。以下のクラスBとクラスCは、ほぼ同じ機能を実現する。

class B
  attr_accessor :var1, :var2

  def initialize(var1, var2)
    @var1, @var2 = var1, var2
  end
end

B.new("bar", 5)
=> #<B:0x007fae7a93ddb8 @var1="bar", @var2=5>
class C < Struct.new(:var1, :var2)
end

C.new("foo", 3)
=> #<struct C var1="foo", var2=3>

Structを使うとインスタンス変数が生成されるわけではないけど、アクセッサ経由で読み書きできるというインターフェイスは同じ。振る舞いが少なく、属性だけがいろいろとあるクラスをサッと定義したいようなときにStructは使える。テストコードのモックオブジェクトを作るようなときにも使われているようだ。

アクセッサが定義されているのは以下のように確認できる。

> Point = Struct.new :x, :y
=> Point
> p = Point.new 3, 4
=> #<struct Point x=3, y=4>
> Point.instance_methods false
=> [:x, :x=, :y, :y=]

で、Structには3つの使い方がある。基本的にStruct.newは無名クラス(Classクラスのインスタンス)を生成する。

(1) Struct.newを継承して新しいクラスを定義する

> class Point < Struct.new(:x, :y); end
> p = Point.new(1,2)
=> #<struct Point x=1, y=2>
> p.x
=> 1
> p.x = 3
=> 3
> p.x
=> 3

(2) Struct.newの返り値がClassクラスのインスタンスなのでそれを定数に入れる
> P = Struct.new(:x, :y)
> p = P.new 1, 2
=> #<struct P x=1, y=2>

(3) Struct.newの第1引数に文字列でクラス名を渡す
> Struct.new("Point", :x, :y)
=> Struct::Point
> p = Struct::Point.new 1, 2
=> #<struct Struct::Point x=1, y=2>

最初の2つはほぼ同義。ただ、2つ目のやり方だと、クラスに振る舞いを付け加えるときに再び明示的にクラスをオープンすることになるので、やや不自然。

Point = Struct.new(:x, :y)
class Point
  def distance
    Math::sqrt(x**2 + y**2)
  end
end

Point.class_eval do
  def minus
    x - y
  end
end

p = Point.new(3, 4)
p p
p p.distance
p p.minus

# #<struct Point x=3, y=4>
# 5.0
# -1

……と思ったけど、実はStruct.newはブロックを受け取って、それをmodule_evalするというアンドキュメンテッドな機能がある。これを使うと以下のように書ける。

Point = Struct.new(:x, :y) do
  def distance
    Math::sqrt(x**2 + y**2)
  end
end

p Point.new(3, 4).distance  # => 5.0

ruby/struct.cを見てみると、Struct.newは以下のようになっている。

static VALUE
rb_struct_s_def(int argc, VALUE *argv, VALUE klass)
{
    VALUE name, rest;
    long i;
    VALUE st;
    ID id;

    rb_scan_args(argc, argv, "1*", &name, &rest);
    if (!NIL_P(name) && SYMBOL_P(name)) {
	rb_ary_unshift(rest, name);
	name = Qnil;
    }
    for (i=0; i<RARRAY_LEN(rest); i++) {
	id = rb_to_id(RARRAY_PTR(rest)[i]);
	RARRAY_PTR(rest)[i] = ID2SYM(id);
    }
    st = make_struct(name, rest, klass);
    if (rb_block_given_p()) {
	rb_mod_module_eval(0, 0, st);
    }

    return st;
}

下から4行目ぐらい。ブロックがあれば、module_evalせよとある。このコードは2004年3月に入ったようだ。なぜ9年近く前のことなのに、いまだにドキュメントに書かれていないのだろうか? ちょっと理由が分からない。

Struct.newにブロックを渡す用法には落とし穴がある。ぱっと見ると、クラス定義っぽく見えるけど、class文のようにコンテキストが切り替わらない。

Point = Struct.new(:x, :y) do
  K = 42
  p self  => #<Class:0x007f90d386fcc0>
  def distance
    Math::sqrt(x**2 + y**2)
  end
end

p Point.new(3, 4).distance => 5.0
p Point::K # => st3.rb:9: warning: toplevel constant K referenced by Point::K

ということで、Structのイディオムとしては、

class Point < Struct.new(:x, :y); end

がバランスがいいように思う。Struct.newはsuper経由でも呼べるので、以下のようなこともできる。

class C < Struct.new(:var1, :var2)
  def initialize(var1, var2)
    s = var1.to_sym
    super(s, var2)
  end
end

C.new("foo", 3)
=> #<struct C var1=:foo, var2=3>

そもそもJavaScriptとかを見ていると、もうなんでもハッシュでいいんじゃないのという気もするわけだけど、ハッシュだと、

  • 属性定義が動的すぎる
  • 属性名のスペルミスに気付きづらい

ということ。逆に、実行時になるまで属性の数や種類が分からないときはハッシュのほうがいい。

参考にしたページ: