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で指定できないので出来るようになって欲しいです。

Leave a Comment

CAPTCHA