複数の要素からなるリストを扱う型としてはArray(配列)がその代表格ですが,それ以外にも「配列」っぽい型がいくつか存在しています。ここでは似ているようで異なるこれらの型を簡単にご紹介します。
Slice(T)
Sliceにリテラル表現はありませんが,Arrayリテラルとよく似たSlice[...]
という特殊な構文(マクロ)でSliceオブジェクトを生成することができます。
slice = Slice[1, 2, 3]
p slice[0]
#=> 1
Arrayと同様,Sliceもジェネリクス型で,型引数によって要素として受け入れられるオブジェクトが制限されますが,上記の構文を使用した場合は,要素の型から自動的にSliceの型引数が設定されます。
slice2 = Slice[1, "2", '3']
p typeof(slice2)
#=> Slice(Char | Int32 | String)
使い勝手の面からするとSlice型は「生成後に要素の数を変更できない固定長のArray」であるかのように見えます。基本的に可変のオブジェクトで#[]=
メソッドによる要素の変更は可能ですが,#<<
や#push
,#shift
のような実行後に要素数が変化するメソッドは使用できません。
slice[0] = 0
p slice
#=> Slice[0, 2, 3]
slice << 4
#=> Error in line 7: undefined method '<<' for Slice(Int32)
#
# Rerun with --error-trace to show a complete error trace.
ただし,生成時のオブションによってはリードオンリーな(不変の)Sliceを作ることも可能です。
slice3 = Slice["1", "2", "3", read_only: true]
slice3[0] = "0"
#=> Can't write to read-only Slice (Exception)
# 0x5580d04f9b98: check_writable at /usr/lib/crystal/slice.cr 496:5
# 0x5580d04f9af4: []= at /usr/lib/crystal/slice.cr 174:5
# 0x5580d04a780e: __crystal_main at /eval 3:1
# :
# :
特に注意が必要なのはArrayと大きく挙動が異なる#+
メソッドです。Arrayの#+
はArrayオブジェクトを引数に取り,自身と引数を結合したArrayを返しますが,Sliceの場合は整数値を引数に取り,引数の数だけ先頭から要素を取り除いたSliceを返します。
array = [1, 2, 3]
p array + [4]
#=> [1, 2, 3, 4]
p slice + 1
#=> Slice[2, 3]
実際のところ,Sliceが内部で保持しているのはポインタ(Pointer)です。ポインタはデータが始まるメモリ番地でしかなく,どこまでが有効なデータなのかのチェックが行われません。そのため,不用意にポインタを用いると,本来アクセスすべきでないメモリ番地の情報を読み書きできてしまいます。そこで,予めデータが始まるメモリ番地(ポインタ)に加えてデータの大きさを指定しておき,バッファオーバーフローなどを回避するための仕組みがSliceというわけです。
Crystalだけでコードを書いているとポインタを扱う様な機会はそうありませんが,例えば,C言語で書かれた関数にポインタを渡す様な場合などではSliceがよく用いられます。また,特に8bit単位のバイトデータを扱うSlice(UInt8)には,Bytesという別名が用意されており,I/Oや文字列処理などでバイト列の操作が必要な場合(IO#read
, IO#write
やString#to_slice
など)にはこのBytes型を使用することになります。
*Tuple(T)
Tupleオブジェクトは,Tuple.new
に要素のリスト渡すことで生成できますが,それ以外にもTuple.from
メソッドにArrayを渡したり,専用のリテラル構文{...}
を用いて生成することができます。
# 全て同じ結果
p tuple = Tuple.new(1, "2", '3')
p tuple = Tuple(Int32, String, Char).from([1, "2", '3'])
p tuple = {1, "2", '3'}
#=> {1, "2", '3'}
Tupleは,ぱっと見は「一旦生成されたら一切の変更がきかない不変なArray」のように見えます。
不変なオブジェクトであるため,要素の追加/削除を伴う#<<
や#push
,#shift
などが利用できないだけでなく,すでに存在する要素を別のオブジェクトに更新する#[]=
なども使用できません。
ArrayやSliceと異なるTupleの一番大きな特徴は,格納した要素の型の取り扱いです。Tuppleでは何番目の要素がどの型の値であるかを内部に保持しているため,#[]
などで値を取り出した際にその型固有のメソッドをそのまま使用することができます。
t1 = tuple[1]
p typeof(t1)
#=> String
p t1.size
#=> 1
一方,ArrayやSliceでも内部に複数の型を格納することは可能ですが,そうした場合,各要素の型は格納した要素の型全てのユニオン型になり,個々の要素の実際の型は隠蔽されてしまいます。そのため,同様のことをArrayで行おうとすると,if
文などで実際の値の型を特定する必要があります。
array = [1, "2", '3']
a1 = array[1]
p typeof(a1)
#=> (Char | Int32 | String)
# この状態ではString型に固有のメソッドは利用不可
p a1.size
#=>Error in line 5: undefined method 'size' for Char (compile-time type is (Char | Int32 | String))
if a1.is_a?(String)
# ここではa1は確実にString型
p a1.size
#=> 1
end
(Tuple生成のサンプルコードで,Tuple.new
では型引数を指定しなくても自動的に判定してくれるのにTuple.from
では必ず型引数を指定する必要があるのもこのことが影響しています。コンパイラからは配列[1, "2", '3']
の要素は全て**(Char|Int32|String)**型の値に見えており,何番目の要素が実際にはどの型なのかを判別できません。そのため各要素をどの型の値として扱うかを型引数の形で明示する必要があります。)
便利ではあるものの,値の更新ができないため,微妙に使いどころが限られるTupleですが,おそらく一番よく目にするのはメソッドの引数周りではないでしょうか。Crystalではスプラット(*
)記号を使用してメソッドに可変長引数を指定することができます。この際,メソッド側ではTuple型の変数として引数を受け取ることになります。
def foo(*args)
p typeof(args)
end
foo(1,"2", '3')
#=> Tuple(Int32, String, Char)
逆に,複数の引数が定義されているメソッドに対して,Tupleを利用して引数を一括して指定することもできます。
def bar(x : Int32, y : String)
p x
p y
end
args = {1, "2"}
bar(*args)
#=> 1
# "2"
Set(T)
Set(集合)はユニークな(重複のない)オブジェクトのコレクションです。
SetオブジェクトはSet.new
にArrayを渡したり,Array#to_set
などの形でArrayから生成することができます。また,専用のリテラル表現はありませんが,Set{...}
という特殊な構文でSetオブジェクトを生成することができます。
# 全て同じ結果
p set = Set.new([1, 2, 3])
p [1, 2, 3].to_set
p Set{1, 2, 3}
#=> Set{1, 2, 3}
他の「配列っぽい」型と異なり,Setの要素には順番がありません。そのオブジェクトがSetに含まれているかどうかのみが考慮されます。そのため,これまでの型では標準的に使えた#[]
にインデックスを指定して要素を取得するという操作がSetできません。また,ほかの型にはない「別のSetとの間で数学的な集合演算を行う」ためのメソッドが用意されています。
set1 = Set{0, 1, 2, 3, 4, 5}
set2 = Set{0, 2, 4, 6, 8, 10}
p set1 | set2 # 和集合
#=> Set{0, 1, 2, 3, 4, 5, 6, 8, 10}
p set1 - set2 # 差集合
#=> Set{1, 3, 5}
p set1 & set2 # 積集合
#=> Set{0, 2, 4}
p set1 ^ set2 # 対象差
#=> Set{1, 3, 5, 6, 8, 10}
Setは要素の同一性を判断するため,内部データの保持にHashを使用しています。つまり,既存の要素と新しいオブジェクトとの重複判定は,ハッシュキーとして同一とみなせるか(#==
と#hash
)によって行われます。
また,仮に格納するオブジェクトの状態を変更可能であったとしても,Setに格納している間は状態変更をすべきではありません。Setにおける重複判定はオブジェクトの格納時にのみ行われ,一旦格納されたあとで要素の状態が変更されないことを想定しています。もしSetに格納した可変なオブジェクトの状態が変更された場合,他の要素と値が重複してしまう可能性があります。
class Person
property name
def_equals_and_hash @name
def initialize(@name : String)
end
def inspect(io)
io << '"' << @name << '"'
end
end
persons = Set(Person).new
john = Person.new("John")
mike = Person.new("Mike")
persons << john << mike
# この時点では各要素はユニーク
p persons
#=> Set{"John", "Mike"}
persons << Person.new("John")
# 同じ値のデータは重複しない
p persons
#=> Set{"John", "Mike"}
# その後に要素の状態を変更すると
mike.name = "John"
# 重複した値が含まれ得る
p persons
#=> Set{"John", "John"}
配列っぽい型のまとめ
型 | インデックス利用 | 要素の追加削除 | 要素の入れ替え |
---|---|---|---|
Array | ◯ | ◯ | ◯ |
Slice | ◯ | × | ◯ |
Tuple | ◯ | × | × |
Set | × | ◯ | × |
【おまけ】配列っぽい型のリテラルっぽい生成法
Setを生成する際のSet{...}
のようなリテラルっぽい動きをする構文は,標準ライブラリだけでなく自作の型でも使用することができます。そのために必要なのは,その型に引数を取らないコンストラクタと,引数を1つ受け取れるインスタンスメソッド#<<
が用意されていることだけ。Arrayのように内部に複数のオブジェクトを格納できるような型にとって便利な機能ではありますが,実際はその型が必ずしもArrayのようなコレクションである必要はありません。
例えば,数値をどんどん加算していくAdderクラスを考えてみましょう。Adder#<<
に整数値(Int32型)を与えていくとAdder#total
で合計値を取得できる,というだけのもので,実装はこんな感じ。
class Adder
getter total
@total = 0
def <<(num : Int32)
@total += num
end
end
実装から想定される基本的な使い方はこう。
add = Adder.new
p add.total
#=> 0
add << 10
p add.total
#=> 10
add << -1
p add.total
#=> 9
このような型でも,引数のないコンストラクタと#<<
メソッドをもっているため,先のリテラルっぽい構文が利用できます。また,そのために実装に手を加える必要はまったくありません。
add = Adder{1, 2, 3}
p add.total
#=> 6