コンテンツにスキップ
{/* SVGアイコンは変更不要 */} {/* SVGアイコンは変更不要 */} {/* SVGアイコンは変更不要 */}

魔法のようなtypeof

Ary Borenzweig

typeofの物語

typeof式の物語は、配列リテラルから始まります。Crystalでは、以下のように書くことができます。

array = [1, 2, 3]

コンパイラは、配列がArray(Int32)、つまり32ビット整数のみを含むことができると推論します。また、以下のように書くこともできます。

array = [1, 'a', true]

コンパイラは、それがArray(Int32 | Char | Bool)であると推論します。ここで、Int32 | Char | Boolはこれらの型の和集合を意味します。配列は、プログラムの実行中にこれらの型のいずれかを保持できます。

配列、ハッシュ、正規表現(regex)リテラルなどの言語のリテラルは、標準ライブラリ呼び出しへの単純な構文書き換えです。正規表現の場合、これは

/fo(o+)/

以下のように書き換えられます。

Regex.new("fo(o+)")

配列リテラルの書き換えには、もう少し考慮が必要です。配列はジェネリックです。つまり、保持できる型を指定する型Tでパラメータ化されます。前述のArray(Int32)Array(Int32 | Char | Bool)などです。リテラル以外の方法で作成するには、以下のようにします。

Array(Int32 | Char | Bool).new

配列リテラルの場合、型は配列リテラル内のすべての要素の和集合型である必要があります。そこで、typeofが誕生しました。最初はtype mergeと呼ばれ、表現できないコンパイラ内部のものでしたが(構文がありませんでした)、コンパイラはこれらのリテラルに使用していました。書き換えの例

array = [1, 'a', true]

# Rewritten to this, where <type_merge>(exp1, exp2, ...) computes
# the union type of the expressions:
Array(<type_merge>(1, 'a', true)).build(3) do |buffer|
  buffer[0] = 1
  buffer[1] = 'a'
  buffer[2] = true
  3
end

これで、このリテラルは通常のメソッドを呼び出して配列を作成しています。問題は、これを書くことができなかったことです:<type_merge>は、型を計算できるこの内部ノードの表現にすぎません。上記を書くと構文エラーが発生します。

私たちは後に、この<type_merge>ノードが非常にうまく機能し、リテラルに魔法がないようにしたい、ユーザーにこの<type_merge>ノードを使用させたいと考え、typeofという名前を付けました。なぜなら、この名前は他の言語ではかなりなじみ深いからです。これでこれを書く

array = [1, 'a', true]

そしてこれ

Array(typeof(1, 'a', true)).build(3) do |buffer|
  buffer[0] = 1
  buffer[1] = 'a'
  buffer[2] = true
  3
end

は完全に等価です。魔法はありません(もちろん、最初の構文の方がはるかに書きやすく読みやすいです)。

typeofが言語に多くの力を与えるとは、当時はほとんど知りませんでした。

typeofの簡単な使い方

typeofの明らかなユースケースの1つは、コンパイラに式の推論された型を尋ねることです。例えば

puts typeof(1) #=> Int32
puts typeof([1, 2, 3].map &.to_s) #=> Array(String)

この時点で、typeof(exp)exp.classに似ていると思うかもしれません。ただし、前者はコンパイル時の型を返し、後者は実行時の型を返します。

exp = rand(0..1) == 0 ? 'a' : true
puts typeof(exp) #=> Char | Bool
puts exp.class   #=> Char (or Bool, depending on the chosen random value)

もう1つの簡単なユースケースは、別のオブジェクトの型に基づいて型を作成することです。

hash = {1 => 'a', 2 => 'b'}
other_hash = typeof(hash).new #:: Hash(Int32, Char)

このようにして、型名を繰り返したりハードコーディングしたりすることを避けることができます。

しかし、これらは面白すぎるほど単純です。

typeofの高度な使い方

Array#compactメソッドを書いてみましょう。このメソッドは、nilインスタンスが削除されたArrayを返します。もちろん、Array(Int32 | Nil)、つまり整数とnilの配列から始めると、Array(Int32)で終わりたいと考えています。

型の文法では、和集合を作成できます。たとえば、Int32 | CharInt32Charの和集合を作成します。ただし、型を減算する方法はありません。T - Nil構文はありません。しかし、typeofを使用すれば、このメソッドを書くことができます。

まず、必要な型になるメソッドを定義します。

def not_nil(exp)
  if exp.is_a?(Nil)
    raise "oops, nil"
  else
    exp
  end
end

expnilの場合、例外が発生します。それ以外の場合は、expを返します。その型を確認してみましょう。

puts typeof(not_nil(1))   #=> Int32
puts typeof(not_nil(nil)) #=> NoReturn

if var.is_a?(…)の仕組みにより、nilではないものを指定すると、型が同じ型であることがわかります。nilを指定すると、ifで実行できる分岐はraiseのみです。さて、raiseにはこのNoReturn型があります。これは基本的に、その式によって値が返されないことを意味します...例外が発生するためです!NoReturnを持つ別の式は、たとえばexitです。

和集合型であるnot_nilを与えてみましょう。

element = rand(0..1) == 0 ? 1 : nil
puts typeof(element)          #=> Int32 | Nil
puts typeof(not_nil(element)) #=> Int32

NoReturn型がなくなっていることに注意してください。最後の式の「予期される」型はInt32 | NoReturn、つまりメソッドの可能な型の和集合になります。ただし、NoReturnには具体的な値がないため、NoReturnを任意の型Tと組み合わせると、基本的にTが返されます。なぜなら、not_nilメソッドが成功した場合(つまり、発生しない場合)、整数が返されます。それ以外の場合は、スタックを通じて例外が発生します。

これでcompactメソッドを実装する準備ができました。

class Array
  def compact
    result = Array(typeof(not_nil(self[0]))).new
    each do |element|
      result << element unless element.is_a?(Nil)
    end
    result
  end
end

ary = [1, nil, 2, nil, 3]
puts typeof(ary)       #=> Array(Int32 | Nil)

compacted = ary.compact
puts compacted         #=> [1, 2, 3]
puts typeof(compacted) #=> Array(Int32)

魔法の行はメソッドの最初の行です。

Array(typeof(not_nil(self[0]))).new

配列の最初の要素でnot_nilを呼び出した結果の型である配列を作成します。コンパイラは配列の各位置にある型を知らないため、01、または123を使用しても同じです。

このようにして、型の文法を拡張することなく、Nilを除外する型を作成することができました。型推論アルゴリズムのコンパイラの機構だけで十分でした。

しかし、これはまだ簡単です。**本当に**面白くて楽しいものに移りましょう。

typeofの魔法

次のタスクは、Array#flattenを実装することです。このメソッドは、元の配列を1次元的にフラット化したもの(再帰的)であるArrayを返します。つまり、配列であるすべての要素について、その要素をこの新しい配列に抽出します。

これが再帰的に機能する必要があることに注意してください。期待される動作を見てみましょう。

ary1 = [1, [2, [3], 'a']]
puts typeof(ary1)             #=> Array(Int32 | Array(Int32 | Array(Int32) | Char))

ary1_flattened = ary1.flatten
puts ary1_flattened           #=> [1, 2, 3, 'a']
puts typeof(ary1_flattened)   #=> Array(Int32 | Char)

前と同様に、フラット化された配列に必要な型を持つメソッドを書いてみましょう。

def flatten_type(object)
  if object.is_a?(Array)
    flatten_type(object[0])
  else
    object
  end
end

puts typeof(flatten_type(1))                          #=> Int32
puts typeof(flatten_type([1, [2]]))                   #=> Int32
puts typeof(flatten_type([1, [2, ['a', 'b']]]))       #=> Int32 | Char

メソッドは簡単です。オブジェクトが配列の場合、その要素のいずれかのフラット化された型が必要です。それ以外の場合は、型はオブジェクトの型です。

これで、flattenを実装する準備ができました。

class Array
  def flatten
    result = Array(typeof(flatten_type(self))).new
    append_flattened(self, result)
    result
  end

  private def append_flattened(object, result)
    if object.is_a?(Array)
      object.each do |sub_object|
        append_flattened(sub_object, result)
      end
    else
      result << object
    end
  end
end

この2番目の例では、配列のフラット化である型を作成することができました。

結論

結局のところ、typeofには本当に魔法のようなものは何もありません。式の型を非常によく推論するコンパイラの能力をクエリして使用できるだけです。

前の例では、通常の方法で他の型から型を作成することができました。型とメソッドです。学ぶべき新しいことは何もなく、型について話すための特別な構文もありません。そして、これはシンプルでありながら強力であるため、優れています。