初めに
この記事では、Swiftにおけるジェネリクスについて解説します。
難しい言葉は基本使わず、噛み砕いて説明しますので、この記事が読者の皆さんの理解促進につながれば幸いです。
この記事の対象読者
・Swiftをある程度学習している。
・プロトコルについてざっくり理解している
そもそもジェネリクスとは
ジェネリクスとは一言でいうと、汎用的なプログラムを記述するための機能のことです。
そこで汎用的なプログラムについて理解するために、まずは非汎用的なプログラムを見てみます。
以下は引数として渡された値が等しいかを判別するプログラムです。
//引数がInt型の時
func chackInt(_ x: Int, _ y: Int) -> Bool {
return x == y
}
chackInt(1, 1)
//引数がfloat型のとき
func checkFloat(_ x: Float, _ y: Float) -> Bool {
return x == y
}
checkFloat(1.1, 1.1) // true
//引数がString型のとき
func checkString(_ x: String, _ y: String) -> Bool {
return x == y
}
checkString("apple", "apple") // true
上記のプログラムでは行いたいことは引数2つが同じかどうかの判定ですが、引数の型が異なるといちいち書き換える必要が出てきます😭
これは正直結構めんどうなことですが、ジェネリクスを用いるとこのめんどくささを解消することができます。以下がジェネリクスを用いたプログラムです。
func isEqual<T : Equatable>(_ x: T, _ y: T) -> Bool {
return x == y
}
isEqual("abc", "def") // false
isEqual(1.0, 3.3) // false
isEqual("apple", "apple") //true
上記のようにジェネリクスを用いると、コードを書く量を減らすことができます。
軽く解説すると、上記のプログラムの引数の型Tは、<T : Equatable>
であり、これはEquatableプロトコルに準拠したあらゆる型ということで、StringやInt,Floatといった基本的な型は全てEquatableプロトコルに準拠しているため引数として使用できるということです。(ここは今は読み飛ばしても大丈夫です。)
このように、ジェネリクスの基本的なコンセプトとしては、入力値の型も任意にすることによってプログラムの汎用性を高めるということだけ頭に入れておいて欲しいです。
swiftでは、ジェネリクスは上記で使用したジェネリクス関数とジェネリクス型の2つが用意されています。今回の記事では目にする回数が多い、ジェネリクス関数について解説したいと思います。
ジェネリクス関数について
ジェネリック関数は型引数(型が特定のものに定まっていない引数)を持つ関数のことです。(先程使用した汎用的な関数もジェネリクス関数です。)
ジェネリック関数は以下のように定義できます。
func 関数名<T>(引数: 引数の型) -> 戻り値の型{
//処理
}
//ex
func identity<T>(_ x: T) -> T {
return x
}
identity(1) // 1
identity("abc") // "abc"
ジェネリック関数は、型引数を関数定義の関数名の直後に追加することで定義できます。
ジェネリック関数を用いることで、exのように引数の型が異なっていたとしてもいちいちプログラムを書き換える必要がなくなります。
また、型引数として受け取った型は、引数や戻り値、さらには関数内部でも使用できます。
このようにジェネリック関数は、宣言時は型が定まっていません。
そのため、ジェネリック関数を使用するときは特殊化(型の決定)を行う必要があります。
特殊化の方法は、以下の二通りがあります。
・引数からの型推論による特殊化(ジェネリック関数の引数のうち少なくとも一つの型が型引数となっている時)
。戻り値から型推論による特殊化(ジェネリック関数の戻り値の型が型引数となっていてかつ戻り値の代入先の型が決まっている時)
それぞれ具体例を確認します。
//引数からの型推論による特殊化、つまり引数で型引数を決定している
func sampleFunction<T>(_ argument: T) -> T {
return argument
}
let int = sampleFunction(1) // 1
let string = sampleFunction("a") // "a"
//戻り値から型推論による特殊化、つまり戻り値で型引数を決定している
func someFunction<T>(_ any: Any) -> T? {
return any as? T
}
let a: String? = someFunction("abc") // Optional("abc")
let b: Int? = someFunction(1) // Optional(1)
// let c = someFunction("abc") // Tが決定できずコンパイルエラー
このように、ジェネリクス関数を使用するときは必ず特殊化を行う必要があり、Tが決定できない場合はコンパイルエラーが起きます。
さてここまで読んでジェネリクス関数なんでも引数で入れれそうだと思った方も多いと思います。実際その通りですが、その反面型引数はどのような型でも受け入れるため、型の性質を利用した記述ができないという欠点😭がありました。
その欠点を解消するのが型制約という仕組みです。これは簡単にいうと、自由度が高すぎる型引数に対して制約を設けるということです。型引数に対して必要十分な型制約を与えることで、汎用性と型の性質を利用した具体的な処理とを両立することが可能です。
型制約の種類には、以下の3種類があります。
・スーパークラスや準拠するプロトコルに対する制約
・連想型のスーパークラスや準拠するプロトコルに対する制約
・型同士の一致を要求する制約
それぞれ見ていきましょう。
1. スーパークラスや準拠するプロトコルに対する制約
型引数のスーパークラスに準拠するプロトコルに対しする制約を指定するには、型引数の後に:と続けてプロトコル名やスーパークラス名を指定します。具体例は以下の通りです。
func checkEqual<T : Equatable>(_ x: T, _ y: T) -> Bool {
return x == y
}
checkEqual("abc", "def") // false
上記の例では、引数で使用されている型引数TはEquatableプロトコルに準拠したものに限定しています。(プロトコルはルールブックのようなものだと思っておいてください。)このように限定することで、T型に対してEquatableプロトコルで定義されている==
を使用できます。
簡単に言うと、TはEquatableプロトコルに準拠してる物じゃなきゃダメ! と宣言しているのが<T : Equatable>
です。
2. 連想県のスーパークラスや準拠するプロトコルに対する制約
型引数には、where節を追加できwhere節を用いることで型引数に条件を追加できます。具体的には、where節のなかで条件を追加したいものの後に:をつけて準拠すべきプロトコルやスーパークラスを指定することができます。
func sortElement<T : Collection>(_ argument: T) -> [T.Element]
where T.Element : Comparable {
return argument.sorted()
}
sortElement([3, 2, 1]) // [1, 2, 3]
上記のジェネリック関数sortElement()では、型引数がまずCollectionプロトコルに従っており、尚且つwhere節の内部でT.Element(Tの要素のことです)がComparableプロトコルに従っていることを要求しています。このようにすることで、ジェネリック関数sortElement()の引数は比較可能な要素をもったコレクションに限定されるためソート処理を実装できるようになるのです。
イメージとしては、where節の内部でさらに引数の方が従うべきルールを追加しているとすると理解しやすいと思います。
3. 型同士の一致を要求する制約
先ほど話たwhere節内部でルールを追加する以外に型同士の一致を要求することができます。以下のプログラムを確認してみましょう。
func merge<T : Collection, U : Collection>(
_ argument1: T, _ argument2: U) -> [T.Element]
where T.Element == U.Element {
return Array(argument1) + Array(argument2)
}
let array = [1, 2, 3] // [Int]型
let set = Set([1, 2, 3]) // Set<Int>型
let merge = merge(array, set) // [1, 2, 3, 2, 3, 1]
上記のジェネリック関数merge()ではまず、2つの引数TとUがCollectionプロトコルに従うことを要求しています。そしてwhere節の中でさらに、T.ElementとU.Elementの型が一致することを求めているということです。このような制約を求めることで、配列や集合といったコレクションの種類は問わないもののその要素の型は地位していないといけないといったような限定的な制限を設けることができます。 上記のプログラムでも引数として[Int]型とSet型という、異なる型(集合)でありながらも要素の型は一致しているプログラムを作成できました。
(Extra)Any型との比較
ここからは少しExtraな内容になります。
先ほどまで話していた通り、ジェネリクスは汎用性のあるプログラムを作成するための機能と紹介してきましたが、実はもう1つ、ジェネリクスは型安全性を保った上で汎用性の高いのあるプログラムを作成することができます。
どういうことかというと、型引数は通常の型と同じ安全性を持っているということです。
func identity<T>(_ x: T) -> T {
return x
}
identity(1) // 1
identity("abc") // "abc"
例えば上記のジェネリクス関数identify()では、引数でInt型を渡せば返り値もInt型、引数でString型を渡せば返り値もString型のように引数と戻り値の型が同じである(Tの型である)ことが保証されています。
わかりやすくいうと、渡した型の情報がきちんと失われずに保持されているという意味でこのプログラムは型安全を保つことができるといえます。
つまりジェネリクスは、型安全性を保った上で汎用性の高いのあるプログラムを提供できるということです。
さてここで型安全性を保った上で汎用性の高いのあるプログラムという話をしましたが、Swiftには型安全性ではない汎用性の高いのあるプログラムも存在します。それがAny型による汎用化です。いかに比較するプログラムを書いてみます。
// ジェネリクスを使った関数
func identityWithGeneric<T>(_ argument: T) -> T {
return argument
}
let genericInt = identityWithGeneric(1) // Int型
let genericString = identityWithGeneric("abc") // String型
// Anyを使った関数
func identityWithAny(_ argument: Any) -> Any {
return argument
}
let anyInt = identityWithAny(1) // Any型
let anyString = identityWithAny("abc") // Any型
if let int = anyInt as? Int {
// ここでようやくInt型として扱えるようになる
print("anyInt is \(int)")
} else {
// Int型へのダウンキャストが失敗した場合を考慮する必要がある
print("The type of anyInt is not Int")
}
上記のプログラムの関数はどちらも、引数で受け取った値をそのまま返却する関数です。identityWithAny関数とidentityWithGeneric関数はどちらも汎用性が高くなっており、引数の型に応じてプログラムを書き換える必要はありません。
ですが返り値を見てみると、ジェネリクスを使った関数は型引数によって型がきちんと変化しているのに対して、Anyを用いた関数では返り値は必ずAnyとなっています。
そのため引数を使用する際にはas?
を用いてダウンキャストして、型をAnyからIntやStringに変更してからではないと使用することはできないのです。
つまりAnyを用いると、返り値も必ずAnyになるため実際の方の情報が失われてしまっています。 そのため型安全性は保持されていない汎用的なプログラムということができるでしょう。
まとめ
これまで説明してきた通り、ジェネリクス関数は使いこなせば無駄にコードを書く回数を減らすことができたりするためとても便利です。
ですが使いこなすにはプロトコルやスーパークラスの知識がないと意図せぬ動きをするので注意が必要です。
ここまで読んでいただきありがとうございました🙇♀️