SwiftのDIはProtocolをAnyObjectに準拠させるとany Protocolに注入してもメモリを浪費しない
Last Updated on 2023年11月12日 by lemonade
概要
Swift 5.6や5.7でanyやsomeなどのキーワードが追加されました。あまりiOSアプリ開発などでDIなどをする機会は少ないかもしれませんが、私は基本サーバーサイドでしかSwiftを使用しないのでDIを頻繁に使います。そこで、anyやsomeを調査する中でSwiftのDIではメモリ消費が効率的でないということが判明しました。この記事ではどういう場合がメモリを浪費していて、どういう場合であればメモリを浪費しないのかを探求していきます。
anyやsomeとは
元々anyもsomeもなかった際、protocolをポリモーフィズム的な使い方をすると確保されるメモリが多くなってしまっていました。以下のコードを見ると、ポリモーフィズムを使わないインスタンスは8bytesですが、Databaseという抽象Protocolの変数に入れると40bytesになっています。
protocol Database {
func select() -> Int
}
struct 構造体DB: Database {
var data: Int
func select() -> Int {
return data
}
}
let 構造体db = 構造体DB(data: 1)
print(MemoryLayout.size(ofValue: 構造体db)) // 8 bytes
let db: Database = 構造体DB(data: 2)
print(MemoryLayout.size(ofValue: db)) // 40bytes
これは、Javaなどの参照型を中心とし変数は値としてアドレス値を保持するオブジェクト指向言語とは違い、Swiftは構造型を中心とする言語であるため変数に直接値が入ることがあり、どの型の構造体が入るかわからない場合にはその分余分にメモリが確保されるためです。40byte中24byteほどは値のために確保されており、それを超えた場合は専用のヒープに格納されそのアドレスがそこに保存されるようです。その他16byteほどは型の情報とからしいです。
これに対応するためにanyとsomeが生まれたのですが、anyは『コンパイル時にどの型が代入されるか定かでない型』を入れることができ、anyをつけない場合と同様の効果となります。つまり以下のように同じ変数に別の型を代入することができます。
protocol Database {
func select() -> Int
}
struct 構造体DB1: Database {
var data: Int
func select() -> Int {
return data
}
}
struct 構造体DB2: Database {
var data: Int
func select() -> Int {
return data
}
}
var db: any Database = 構造体DB1(data: 1)
db = 構造体DB2(data: 2)
一方someは『コンパイル時にどの型が代入されるかが判る型』になります。anyの場合のような同じ変数に異なる型の値を入れることはできません。しかし、コンパイル時に確保すべきメモリの量が判明するためにメモリを浪費しないというメリットがあります。
protocol Database {
func select() -> Int
}
struct 構造体DB: Database {
var data: Int
func select() -> Int {
return data
}
}
let anyDB: any Database = 構造体DB(data: 1)
let someDB: some Database = 構造体DB(data: 1)
print("anyDB: \(MemoryLayout.size(ofValue: anyDB))") // 40 bytes
print("someDB: \(MemoryLayout.size(ofValue: someDB))") // 8 bytes
つまり、メモリを浪費しないためには出来るだけanyではなくsomeを使用すべきということになります。
計測
実際にDIを計測して、浪費するパターンとしないパターンを確かめていきます。
any Protocolのプロパティを持つ構造体に構造体を注入した場合はメモリ消費が大きい
構造体のDBを構造体のServiceに注入します。ServiceはDatabaseをanyで保持しています。DIする際にはどの型を注入するかは決まっているはずなので、DIコンテナの中ではsomeを返すようになっています。
protocol Database {
func select() -> Int
}
struct 構造体DB: Database {
var data: Int
func select() -> Int {
return data
}
}
protocol Service {
func double() -> Int
}
struct 構造体Service: Service {
var db: any Database
func double() -> Int {
db.select() * 2
}
}
struct DI {
var db: some Database { 構造体DB(data: 1) }
var service: some Service { 構造体Service(db: db) }
}
let di = DI()
print("any db: \(MemoryLayout.size(ofValue: di.service))") // 40bytes
ジェネリクスのプロパティを持つ構造体に構造体を注入した場合は浪費しない
any Protocolを使わずに、ジェネリクスで使用した場合コンパイル時にどの型が入るかは判別できるため、メモリ消費は大きくなりません。ただこの方法、コードの分量がすごく増えて書きづらさが出てきます。
protocol Database {
func select() -> Int
}
struct 構造体DB: Database {
var data: Int
func select() -> Int {
return data
}
}
protocol Service {
func double() -> Int
}
struct 構造体Service<DB: Database>: Service {
var db: DB
func double() -> Int {
db.select() * 2
}
}
struct DI {
var db: some Database { 構造体DB(data: 1) }
var service: some Service { 構造体Service(db: db) }
}
let di = DI()
print("generics db: \(MemoryLayout.size(ofValue: di.service))") // 8 bytes
any Protocolのプロパティを持つ構造体にクラスを注入した場合もメモリ消費が大きい
参照型であるクラスで作られたDatabaseなら注入してもアドレス値が入るのでメモリ消費が少なくなるのではないか。そう思い計測してみましたが、やはりclassを代入しても構造体を代入しても確保されるメモリは変わりませんでした。
protocol Database {
func select() -> Int
}
final class 参照型DB: Database {
var data: Int
init(data: Int) {
self.data = data
}
func select() -> Int {
return data
}
}
protocol Service {
func double() -> Int
}
struct 構造体Service: Service {
var db: any Database
func double() -> Int {
db.select() * 2
}
}
struct DI {
var db: some Database { 参照型DB(data: 1) }
var service: some Service { 構造体Service(db: db) }
}
let di = DI()
print("any db: \(MemoryLayout.size(ofValue: di.service))") // 40 bytes
参照型のクラスがany Protocolをプロパティに持っている場合は注入してもメモリを浪費しないように見えて普通に浪費する
そもそもServiceが構造体である必要ってあまりなく、値として使わないのでclassで扱っても特に問題は起きません。Equatableなどが使いづらくなりますがそもそも比較するときがありません。
protocol Database {
func select() -> Int
}
struct 構造体DB: Database {
var data: Int
func select() -> Int {
return data
}
}
protocol Service {
func double() -> Int
}
final class 参照型Service: Service {
var db: any Database
init(db: any Database) {
self.db = db
}
func double() -> Int {
db.select() * 2
}
}
struct DI {
var db: some Database { 構造体DB(data: 1) }
var service: some Service { 参照型Service(db: db) }
}
let di = DI()
print(MemoryLayout.size(ofValue: di.service)) // 8 bytes
print(MemoryLayout.size(ofValue: (di.service as! 参照型Service).db)) // 40 bytes
計測してみると、classで作成されたServiceには注入してもメモリが大きく確保されませんでした。しかし、クラスは参照型なので、アドレス値が8bytesであるだけで、保持しているdb自体は40bytesでした。
ProtocolをAnyObjectに準拠させるとメモリ消費が少ない
AnyObjectは、すべてのクラスが暗黙的に準拠するプロトコルです。構造体はこのProtocolに準拠させることができなくなります。これに準拠させると、anyでもメモリが少なくて済みました。しかし、dbプロパティは16bytesでその理由はわかりませんでした。
protocol Database: AnyObject {
func select() -> Int
}
protocol Service: AnyObject {
func double() -> Int
}
class 参照型DB: Database {
var data: Int
init(data: Int) {
self.data = data
}
func select() -> Int {
return data
}
}
final class 参照型Service: Service {
var db: any Database
init(db: any Database) {
self.db = db
}
func double() -> Int {
db.select() * 2
}
}
struct DI {
var db: some Database { 参照型DB(data: 1) }
var service: some Service { 参照型Service(db: db) }
}
let di = DI()
print(MemoryLayout.size(ofValue: di.service)) // 8 bytes
print(MemoryLayout.size(ofValue: (di.service as! 参照型Service).db)) // 16 bytes
print(MemoryLayout.size(ofValue: ((di.service as! 参照型Service).db as! 参照型DB).data)) // 8 bytes
最後に
この辺り、あまりよくわかっていないのですがとりあえず計測してみてメモを残したかったので書いてみました。
メモリ消費量的にはsomeにするためジェネリクスかAnyObjectでDIをするのが良さそうです。単一関数では引数にsome Protocolを指定できるのですが、現在propertyにsome Protocolで指定できないので出来るようになって欲しいです。