💎

【Crystalと○○】Crystalとインスタンス変数

2021/04/19に公開

📖【Crystalと○○】コンテンツ一覧

Crytalでは値を保持する枠組みとして、ローカル変数、インスタンス変数、クラス変数と定数を利用できますが、今回はこの中からインスタンス変数の使い方や特徴を説明することにしましょう。

インスタンス変数はインスタンスごとに固有の情報を保持するためにクラスや構造体の内部で定義されるもので、同じ型のインスタンスであっても、そのインスタンス変数はそれぞれのインスタンスごとに異なります。

インスタンス変数の命名規則

インスタンス変数の変数名は @ に続く小文字のスネークケースで記載します。

class SomeClass
  @instance_var : String
end

インスタンス変数の型と初期化

ローカル変数と異なり、インスタンス変数はコンパイル時点でその型が確定されている必要があります。

一番基本的な型の確定方法は上の例でもあるように、型定義の直下(メソッド定義の外側)で @instance_var : Type という形の明示的な型指定を記述するというものです。
このようにして型が明示されたインスタンス変数に、他の型の値を代入しようとするとコンパイルエラーになります。

class SomeClass
  @instance_var : String

  def initialize(i : Int32)
    @instance_var = i
  end
end

SomeClass.new(1)
#=> Error: instance variable '@instance_var' of SomeClass must be String, not Int32

ただし、明示的な型の指定がない場合も、コンパイラはインスタンス変数の初期化時に代入された値からインスタンス変数の型を推論しようとします。コンパイラが型を推論可能な初期化の条件のうちよく利用されるのは以下の5種類です。

1. リテラル値で初期化
@instance_var  = 1   # @instance_var  : Int32
2. ある型のコンストラクタで初期化
@instance_var  = Set(String).new # @instance_var  : Set(String)
3. 返り値の型が明示されているクラスメソッドで初期化
@instance_var  = Time.parse("1900/01/01") # @instance_var  : Time
4. 型制約のついたメソッド引数で初期化
def initialize(s : String)
  @instance_var  = s # @instance_var : String
end
5. デフォルト値が指定されたメソッド引数で初期化
def initialize(s = "String")
  @instance_var = s # @instance_var : String
end

設定されたデフォルト値から、上記 1. 〜 3. の方法で型を特定。

このほか、やや特殊な利用ですが、以下のような条件でもコンパイラはインスタンス変数の型を推論してくれます。

6. 未初期化の型が明示された場合
@instance_var = uninitialized String # @instance_var : String
7. Cバインディングの関数(func)で初期化された場合
@instance_var = LibC.socket(socket_family, soket_type, protocol) # @instance_var : LibC::Int

Cバインディングの関数は必ず返り値の型が指定されているため、そこから型の情報を取得します。

8. Cバインディングの関数(func)に out キーワド付きで渡された場合
LibC.gettimeofday(out @instance_var, nil) # @instance_var : LibC::Timeval

Cバインディングの関数は必ず引数の型が指定されているため、そこから型の情報を取得します。

型定義の直下で明示的に型が指定されていないインスタンス変数に対して、コンストラクタの終了までに異なる型の値が複数代入された場合、そのインスタンス変数の型は代入された値から推論される型すべてのユニオン型になります。

class SomeClass
  @instance_var = "s"     # : String

  def initialize
    @instance_var = 1.0   # : Float64
  end

  def initialize(i : Int32)
    @instance_var = i     # : Int32
  end

  def instance_var_type
    typeof(SomeClass.new.instance_var)
  end
end

SomeClass.new.instance_var_type
#=> (Float64 | Int32 | String)

ただし、型定義の直下で明示的に型が指定されている場合には、コンストラクタ内で異なる型の値で初期化しようとするとコンパイルエラーになります。

class SomeClass
  @instance_var : String

  def initialize
    @instance_var = 1
  end
end

SomeClass.new
# Error: instance variable '@instance_var' of SomeClass must be String, not Int32

コンストラクタの終了時までに初期化されなかったインスタンス変数は暗黙的に nil で初期化され、もしそのインスタンス変数の型が nilable でない(Nil 型とのユニオン型でない、値が nil であることを許容しない)場合には、型チェックに違反してコンパイルエラーが発生します。

class SomeClass
  @instance_var : String

  def initialize()
  end
end
# Error: instance variable '@instance_var' of SomeClass was not initialized directly in all of the 'initialize' methods, rendering it nilable. Indirect initialization is not supported.

そのため、全ての nilable でないインスタンス変数は、コンストラクタが終了するまでに何らかの値(uninitialized 含む)で初期化が完了していなければなりません。

なお、Crystalではオーバーロードによって複数のコンストラクタを定義可能ですが、nilable でないインスタンス変数の初期化は、どのコンストラクタ経由でインスタンスが生成された場合であっても行われる必要があります。もし、複数あるコンストラクタのうち一部で初期化されていない(nilable でない)インスタンス変数があると、やはりコンパイルエラーとなり、この時はさらに詳細なエラーレポートを表示します。

詳細なエラーレポートのサンプル
class SomeClass
  @instance_var : String

  def initialize()
  end

  def initialize(s : String)
    @instance_var = s
  end
end

SomeClass.new("s")
# Error: this 'initialize' doesn't explicitly initialize instance variable '@instance_var' of SomeClass, rendering it nilable
# 
# The instance variable '@instance_var' is initialized in other 'initialize' methods,
# and by not initializing it here it's not clear if the variable is supposed
# to be nilable or if this is a mistake.
# 
# To fix this error, either assign nil to it here:
# 
#   @instance_var = nil
# 
# Or declare it as nilable outside at the type level:
# 
#   @instance_var : (String)?

また、Crystalでは、型定義の直下でもインスタンス変数の初期化(デフォルトの初期化)を行うことが可能です。

class SomeClass
  @instance_var = "s"
end
p SomeClass.new
#=> #<SomeClass:0x10851ae80 @instance_var="s">

このようにして行われた初期化は、コンストラクタの開始前に適用されますので、複数のコンストラクタで同じ値によるインスタンス変数を初期化するような場合に便利です。また、コンストラクタが1つしかない場合であっても、固定の初期値を持つようなインスタンス変数は型定義の直下で初期化しておくと、コンストラクタ内の記述がシンプルになってコードの見通しがよくなります。

インスタンス変数のスコープ

インスタンス変数のスコープはそのインスタンス内となり、同じ型であっても異なるインスタンスとは共有されません。

インスタンスの外から直接アクセスすることもできませんので、外側からインスタンス変数の値を取得したり更新したりするためにはゲッタメソッドやセッタメソッドなどのアクセサメソッドを介する必要があります。

class SomeClass
  @instance_var = 1

  # getter
  def instance_var
    @instence_var
  end

  # setter
  def instance_var=(value)
    @instance_var = value
  end
end

some_class = SomeClass.new

some_class.instance_var = 2

some_class.instance_var #=> 2

また、ある型で定義されたインスタンス変数は、その型を継承/mix-inした先の型でも利用可能です。

class OtherClass < SomeClass
  def double
    @instance_var *= 2
  end
end

other_class = OtherClass.new
other_class.double
other_class.instance_var #=> 2 (初期値 1 が #double で2倍になった)

メソッド引数のインスタンス変数渡し

メソッド引数で受け取った値をインスタンス変数に代入したい場合、Rubyだと、メソッド引数は一旦別の変数で受けてからインスタンス変数へ代入する必要がありましたが、Crystalではメソッドの仮引数にインスタンス変数を指定することで、メソッド引数の値を直接インスタンス変数で受け取ることができます。

コンストラクタやゲッタメソッドを記述する際に地味に便利な構文です。

Rubyの場合
class SomeCalss
  def initialize(ivar)
    @ivar = ivar
  end

  def ivar=(ivar)
    @ivar = ivar
  end
end
Crystalの場合
class SomeCalss
  def initialize(@ivar : String)
  end

  def ivar=(@ivar)
  end
end

今回のまとめ

  • インスタンス変数は @ で始まる変数で、インスタンスごとに独立した値を持つ
  • 型の宣言は必須ではないが、オブジェクトの初期化時点で型が特定できなければならない
  • メソッドの引数を直接受けられて便利

Discussion