【Crystalと○○】 Crystalと型

10 min読了の目安(約9300字TECH技術記事

単一継承の型にモジュールで機能を mix-in できるCrystalの型は、Rubyから直接的な影響を受けていますが、ユニオン型やジェネリック型、はたまた抽象型など、Rubyにはない特徴も備えています。

今回は、Crystal言語で使用できる型の種類やその考え方について簡単にご紹介しましょう。

個々の型の詳細な説明はいずれそれぞれ個別のアーティクルでご紹介できればと思います。

定義可能な型の種類

Crystalで定義可能な型は基本的に以下の5種類です。

  • クラス(class)
  • 構造体(struct)
  • モジュール(module)
  • 列挙型(enum)
  • エイリアス(alias)

より厳密には、クラスと構造体にはそれぞれ抽象型(abstract type、後述)が存在しており、この抽象クラス(abstract class)と抽象構造体(abstract struct)を含めると結果的に計7種類の定義が可能です。

また、Cバインディングを記述する際のライブラリ(lib)定義も型と言えなくはない感じですが、少々特殊なためここでは触れません。

クラス(class)

Crystalにおいて、いわゆる「型」というイメージに最も適合するのがクラスです。

class Foo
  # ...
end

クラスはその内部にインスタンス変数やクラス変数、定数をもち、インスタンスメソッドやクラスメソッドを定義することができます。

また、クラスはインスタンス化が可能で、他のクラスを継承したり、他のクラスの継承元になったりすることで型の継承ツリーを構成します。

クラスのインスタンスは、メソッドの引数として渡される際や他の変数に代入される際などには 参照渡し になります。

構造体(struct)

構造体は、インスタンス化が可能で、他の型と継承関係を持つという点でクラスとよく似ています。

struct Foo
  # ...
end

やはりその内部にインスタンス変数やクラス変数、定数をもち、インスタンスメソッドやクラスメソッドを定義可能であるところもクラスとよく似ています。(構造体の場合も クラス 変数、クラス メソッドと呼びます)

ただし、他の型との継承関係を持てるとはいえ、ユーザが定義可能なのは抽象構造体を継承した通常の構造体のみであり、クラスを継承した構造体や、ほかの(抽象型でない)構造体を継承した構造体を定義することはできません。

継承先\継承元 クラス 抽象クラス 構造体 抽象構造体
クラス × ×
抽象クラス × × ×
構造体 × × ×
抽象構造体 × × ×

また、メソッドの引数として渡される際や他の変数に代入される際などには 値渡し になります。ですので、構造体を引数として渡したメソッド内で、引数の構造体に変更を加えても、メソッドの外の元の構造体には影響しません。

そして、インスタンス生成の際にヒープ領域にメモリを割り当てるクラスに対して、構造体はスタック領域を使用します。通常ヒープ領域に比べて小さいスタック領域ですが、一方でヒープ領域よりも非常に高速に読み書きが可能です。

この様な特徴から、構造体は、大量の細かなヒープ割り当てを行うよりもスタック上の小さなコピーを渡した方がパフォーマンスが向上する場合に、状態変更が起こらない(immutableな)オブジェクトや、それ自身が状態を持たない他のオブジェクトのラッパとして使用するのに向いています。

モジュール(module)

モジュールは、クラスや構造体あるいは他のモジュールに組み込むことができる機能のパッケージです。

module Foo
  # ...
end

クラスや構造体のように、その内部にインスタンス変数、クラス変数、定数、インスタンスメソッド、クラスメソッドなどを定義可能ですが、モジュールを直接インスタンス化することはできません。その代わりに、モジュールを他の型に include する(mix-in)ことで、include した側の型からモジュールに定義した機能を利用可能になります。

こうした仕組みが必要な理由は、Crystalの型システムは単一継承であるためです。単一継承ではある型の継承元は1つしか指定できませんので、機能を小さな単位に分割して必要に応じてそれらを取り込む、といった使い方を継承では実現できません。

例えば、地上を走行する Car 型、海上を航行する Ship 型、空中を飛行する Plane 型などの継承元として乗り物である Vehicle 型があり以下のような実装になっているとします。

class Vehicle
end

class Car < Vehicle
  def drive
    # ...
  end
end

class Ship < Vehicle
  def seil
    # ...
  end
end

class Plane < Vehicle
  def fly
    # ...
  end
end

この場合、ここに水陸両用車(Amphibious 型)を追加しようとすると Car 型が持つ #drive メソッドと Ship 型が持つ #seil メソッドを Amphibious 型でもう一度定義しなければならず、同じコードの重複によるメンテナンス上の問題が発生します。

class Amphibious < Viecle
  def drive
    # ...
  end

  def seil
    # ...
  end
end

このような時には、まず「走行する機能」、「航行する機能」、「飛行する機能」をモジュールとして切り出して定義します。

module LandVehicle
  def drive
    # ...
  end
end

module WaterVehicle
  def seil
    # ...
  end
end

module AirVehicle
  def fly
    # ...
  end
end

その後、これらのモジュールをそれぞれのクラスに inlcude することで、使用する機能を取捨選択しつつ、コードの重複を排除できます。

class Car < Vehicle
  include LandVehicle
end

class Ship < Vehicle
  include WaterVehicle
end

class Plane < Vehicle
  include AirVehicle
end

class Amphibious < Viecle
  include LandVehicle
  include AirVehicle
end

こうしておくと、後で水上機(Hydroaeroplane 型)が必要になった際も WaterVehicleAirVehicleinclude することで実現できます。

列挙型(enum)

列挙型はある特定用途のために名前をつけた整数値の集合です。

enum Direction
  North #=> 0
  South #=> 1
  East  #=> 2
  West  #=> 3
end

特に使用する整数の型や個々の名前に対する値を指定しなかった場合は、それぞれの名前に対して定義した順に Int32 型(32bit符号付き整数)の0から値が振られます。

列挙型を使用すると、コード内でその値が何を意味するかが明示されるため可読性が向上します。

# 0 が北だと知っていないと引数が方角を指していることすらわからない
walk(0)

# 方角の北であることが明確
walk(Direction::North)

ただし、これだけであれば、値に対応する定数を定義することでもある程度代用可能です。列挙型の便利な点としては、型として定義されることでコンパイラによる型チェックの対象となり、メソッド引数などの型制約により不正な値の入力を防止可能な点があります。

walk メソッドの定義が以下のようになっていた場合、実際には方角に対応する数値以外も渡せてしまいますので、メソッド内で個別に数値のチェックを行なわない限り、実行時に不正な値を受け取ってエラーが発生するリスクが残ります。

NORTH = 0
SOUTH = 1
EAST  = 2
WEST  = 3

def walk(dir : Int32)
  # ...
end

# 引数が不正な値であってもコンパイル時に補足できない
walk(4) 

もし、walk メソッドが引数として Direction 型の値を要求するよう宣言されていれば、Direction 型の東西南北以外の値を受け取るような状況が発生するとコンパイル時に捕捉することができます。

def walk(dir : Direction)
  # ...
end

# Direction 型以外の引数はコンパイル時にエラー
walk(4)
# Error: no overload matches 'walk' with type Int32

エイリアス(alias)

エイリアスは型に別名をつける機能です。

alias Heitght = Int32

上の例では身長(Height)型を Int32 型の別名として定義しています。

この時、コンパイラ的には Height 型と Int32 型は完全に同一のものとして扱われますので、Int32 型の引数を要求するメソッドに対して Height 型の値を渡すことも可能です。

エイリアスの代表的な利用シーンは2つありあます。

一つ目は、ある型を特別な用途で使用することを明示する場合で、標準添付ライブラリでは、Slice(UInt8) 型に対するエイリアス Bytes 型がこのタイプです。この場合、8bit符号なし整数値のスライス(サイズ指定付きのリストポインタ)をバイト列として使用することを示しています。

もう一つは、長い型表記をシンプルに記述する場合です。標準添付ライブラリでは Colorize::Color 型がこのタイプのエリアスです。Colorize モジュールでは Colorize::Color256 型、Colorize::ColorANSI 型、Colorize::ColorRGB 型のいずれかで色を指定できます。こうした場合エイリアスを使わないと毎回 (Colorize::Color256 | Colorize::ColorANSI | Colorize::ColorRGB) と記述する必要がありますが。エイリアスを使うことでこの記述を Colorize::Color と置き換えることができます。

ジェネリック型

ジェネリック型は、様々な型に対する共通の枠組みを定義する際に使用されます。

例えば、配列(Array 型)は要素として様々な型の値を収めることができますが、末尾に要素を追加(#push)したり、逆に末尾の要素を取り出し(#pop)たりと、要素の型が何であっても共通な機能を有しています。

ですので、Crystalの Array 型はジェネリック型として、以下の様に定義されています。

class Array(T)
  # ...
end

このときの T は型引数と言い、実際に配列を使用する際には T の代わりに要素として受け取る型を指定します。例えば、文字列(String)型の要素を持つ配列は Array(String) 型で、File 型の要素を持つ配列は Array(File) 型になります。

型引数はその型の中では通常の型名と同様に使用するととができ、例えば引数を1つ取る Array(T)#push メソッドは次のように定義されています。

class Array(T)
  # ...
  def push(value : T)
    # ...
  end
end

このジェネリック型の代表的な利用シーンは2つあります。

一つ目は、要素の型に縛られないコレクションやコンテナを定義する場合です。先ほどの配列だけでなく、ハッシュもキーと値の型をそれぞれ型引数として取る Hash(K,V) 型として定義されています。

もう一つは、内部に様々な型のオブジェクトを治められるラッパを定義する場合です。こちらの例には収めたオブジェクトのポインタを扱う Pointer(T) 型があります。

ユニオン型

ユニオン型は複数の型を組み合わせた状態の型で、A 型と B 型から構成されるユニオン型は A | B 型(もしくは明示的にカッコで括って (A | B) 型)と表記します。この型の値は、実行時に A 型か B 型のどちらの型の値にもなりうることを示しています。

また、特に A 型と Nil 型とのユニオン型は A? 型と表記することもできます。

ユニオン型が登場するパターンには、コンパイラによってユニオン型と推論される場合と、コード中で明示的にユニオン型を指定する場合があります。

前者の例は以下のような場合です。

if rand() > 0.5
  a = 1
else
  a = "1"
end

typeof(a)
#=> (Int32 | String)

ローカル変数 a は実行時に if 文の条件が成立した場合には 1Int32 型)になり、そうでなければ "1"String 型)になります。typeof 文の時点で変数 a の値は Int32 型である状況と String 型の状況がどちらも考えられるため、コンパイラはこの時点の変数 a の型を (Int32 | String) 型であると判断します。

また、配列やハッシュなどの初期化時に複数の型の要素(ハッシュの場合はキーや値)が含まれていると、それらのコレクションの要素(もしくはキーや値)の型は指定された値の型全てから構成されるユニオン型だとコンパイラは判断します。

foo = [1, "s"]
typeof(foo)
#=> Array(Int32 | String)

一方、後者の例は以下のような場合です。

def search(target : Char | String)
  # ...
end

# 引数を String 型で渡す
search("a")

# 引数を Char 型で渡す
search('a')

この例では、search メソッドの引数に Char | String 型を指定しています。こうすることで、このメソッドが引数として String 型(文字列)の値と、Char 型(単一文字)の値どちらも渡すことができるように(そして、それ以外の型を受け取らないように)指定できます。。

抽象型(absract type)

クラス(class)と構造体(struct)にはそれぞれ抽象クラス(abstract class)、抽象構造体(abstract struct)という抽象型が存在します。

abstract class AbstractClass
  # ...
end

abstract struct AbstractStruct
  # ...
end

これらの抽象型は通常のクラスや構造体と同じように定義できますが、直接インスタンス化することはできません。

AbstractClass.new
#=> Error: can't instantiate abstract class AbstractClass

AbstractStruct.new
#=> Error: can't instantiate abstract struct AbstractStruct

メソッドやプロパティを定義できはするが、直接インスタンス化できない、という点で抽象型はモジュールとよく似ています。

抽象型とモジュールの使い分けについては、明文化されたルールなどは特にないようですが、型の継承関係に組み込みつつそれ自体は直接インスタンス化することがない共通概念的な実装には抽象型を使用し、継承関係とは独立して複数の型に組み込む機能をまとめる際にはモジュールを使用することが多いようです。

その意味では、モジュールの説明の例に登場した Vehicle 型などが、抽象型として実装するのに適した例でしょう。

abstract class Vehicle
end

class Car < Vehicle
end

class Ship < Vehicle
end

class Plane < Vehicle
end

仮想型(virtual type)

これまでに説明した各種の型とは異なり、仮想型は普通にCrystalのコードを書いている中ではなかなかお目にかかることのない型です。

この型は、コンパイラがある変数の型を推論する際に用いる実際には存在しない型です。

仮想型の理解には前提となる仕組みが色々ありますので、いずれ改めてご紹介できればと思います。

おまけ:継承ツリー基底部の特殊な型

継承ツリーの基底部には通常直接使用することの無い特殊な型が定義されています。

Object

全てのクラス、構造体に共通する基底の型が Object 型です。

文字列表現を返す #to_s メソッドや、自身の型オブジェクトを返す #class メソッドなど、全てのオブジェクトが共通して持つ機能はこの Object 型で定義されています。

継承ツリーの基底に Object 型が存在する構成は、Rubyからの影響が見られる特徴の一つです。ただし、Rubyでは明示的に継承元を指定しないクラスは暗黙的に Object クラスを継承しますが、Crystalでは通常は Object 型を直接継承した型を使用することはありません。

その理由は、次にあげる Reference 型と Value 型の存在にあります。

Reference

Reference 型は Object 型を直接継承した型で、明示的に継承元を指定しなかった全ての クラス の暗黙的な継承元になります。

class SomeClass # < Reference
end

Reference (参照)型という名前の通り、この型を継承した全ての型は 参照渡し されます。例えば、メソッドの引数に渡された際や、メソッドの返り値として渡される際、変数に代入される際など、実際にはそのオブジェクトのポインタが渡されることになります。

また、新しいインスタンスを生成する際にはヒープ領域から新たにメモリが割り当てられますが、こうして割り当てられたメモリは不要になればGCによって自動的に解放されるため、通常はメモリ管理を気にする必要はありません。

Value

Value 型は Object 型を直接継承した型で、明示的に継承元を指定しなかった全ての 構造体 の暗黙的な継承元になります。

struct SomeStruct # < Value
end

Value (値)型という名前の通り、こちらの型を継承した全ての型は 値渡し されます。メソッドの引数に渡された際や、メソッドの返り値として渡される際、変数に代入される際など、実際には新たに生成されたオブジェクトのコピーが渡されます。不変な(immutableな)オブジェクトの場合はそれほど気にすることはありませんが、状態が変化する(mutableな)オブジェクト(標準添付ライブラリの中では StaticArray(T,N) 型など)の場合は、注意が必要です。

今回のまとめ

単一継承の型に対してモジュールの mix-in で機能を拡張可能とするCrystalの型に対する考え方の基本的な部分は、Rubyをベースにしているが、静的な型チェックを行ったりパフォーマンスを向上させるための工夫が数多く取り込まれている。