やってみた
2021.01.26
Swiftのnilについて考えてみた
初めまして、金井 大朗と申します!
先日社内にてSwiftのnilとは何なのかという話題が出て口頭で説明しようとしても漠然とnullみたいなもの?としか出てこなかったので改めて調べてみました。
このあたりは興味がある人は既にQiita等で見たことがあるかも知れません。
まずはOptional型について
Optional型とは、nil、もしくは別の値を持つことができる型のことを指します。
Optional型ではない変数にnilをセットすることはできません。
var hoge: String? = nil // OK var fuga: Optional<String> = nil // OK var piyo: String = nil // コンパイルエラー
Optional型の定義は型の後に?をつけるかOptional<型>のように定義することができます。
ではこのOptional型とは一体何者なのでしょうか。
GitHubに公開されているSwiftのOptional.h型のコードを見てみましょう。
template <typename T, bool = is_trivially_copyable<T>::value> class OptionalStorage { union { char empty; T value; }; bool hasVal;
こちらはC++なので見慣れない方がいるかも知れませんが、OptionalStorageというクラスに渡された型を保持するTとTの値を持っているかどうかのbool値を持つクラスになります。
unionの意味はchar型とT型の変数を同じメモリ空間に割り当てるという意味になり、この場合はchar型か渡されたT型のサイズの大きな方をメモリに割り当てることになります。
nilとは?
nilを代入する処理を探す前に力尽きてしまったので以下は推測になります。
恐らくnilとは、Tの実態が定義されておらずhasValがfalseの状態のことを指しているのではないでしょうか。
ここで一つ疑問が出てきました。nilを代入したとしてもOptional型という実体は定義されているようなのでメモリは消費されるのではないでしょうか?
試してみた
簡易的なメモリチェック用のプロジェクトを作成して検証してみます。
単純にボタンが配置されたStoryBoardとボタンを押した際にOptional型の配列にnilを1024 * 1024個追加するものを用意しました。
メモリ使用量の計測関数については以下の関数を利用させて頂いています。
var hoge: [Bool?] = [] var counter = 0; override func viewDidLoad() { super.viewDidLoad() print(String(format:"Bool? Size: %d" ,MemoryLayout<Bool?>.size)) print(String(format:"UInt8? Size: %d" ,MemoryLayout<UInt8?>.size)) } // ボタンが押された際に配列にnilを1024 * 1024個追加する処理 @IBAction func TapButton(_ sender: Any) { counter += 1 print(String(format: "%d回目", counter)) printMemory(header: "StartMemory:") for i in 0..<1024 * 1024 { hoge.append(nil) } printMemory(header: "EndMemory:") } // メモリ表示文を整形 func printMemory(header: String) { if let memory = getMemoryUsed() { print(String(format: "%@%dMB", header , memory)) } } // 使用者が単位を把握できるようにするため typealias MegaByte = UInt64 // 引数にenumで任意の単位を指定できるのが好ましい e.g. unit = .auto (デフォルト引数) func getMemoryUsed() -> MegaByte? { // タスク情報を取得 var info = mach_task_basic_info() // `info`の値からその型に必要なメモリを取得 var count = UInt32(MemoryLayout.size(ofValue: info) / MemoryLayout<integer_t>.size) let result = withUnsafeMutablePointer(to: &info) { task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), // `task_info`の引数にするためにInt32のメモリ配置と解釈させる必要がある $0.withMemoryRebound(to: Int32.self, capacity: 1) { pointer in UnsafeMutablePointer<Int32>(pointer) }, &count) } // MB表記に変換して返却 return result == KERN_SUCCESS ? info.resident_size / 1024 / 1024 : nil }
今回はBool?型とUInt8?型にて検証を行いました。
Bool型とUInt8型はそれぞれ1バイトなのでそれぞれTに値する部分が1バイト、hasValのboolが1バイトの合計2バイトの予想でしたが、MemoryLayoutにてサイズを計測した結果
Bool? Size: 1
UInt8? Size: 2
UInt8?型は想定通りでしたがBool?型は予想と違う結果になりました。
以下Bool?とUInt8?にnilを代入した際のメモリ消費量を12回試行した結果になります。
Bool? | UInt8? | |||
開始時メモリ | 追加後メモリ | 開始時メモリ | 追加後メモリ | |
1回目 | 77MB | 80MB | 77MB | 83MB |
2回目 | 80MB | 83MB | 83MB | 89MB |
3回目 | 83MB | 84MB | 89MB | 91MB |
4回目 | 84MB | 89MB | 91MB | 101MB |
5回目 | 89MB | 90MB | 101MB | 103MB |
6回目 | 90MB | 91MB | 103MB | 105MB |
7回目 | 91MB | 92MB | 105MB | 107MB |
8回目 | 92MB | 101MB | 107MB | 125MB |
9回目 | 101MB | 102MB | 125MB | 127MB |
10回目 | 102MB | 103MB | 127MB | 129MB |
11回目 | 103MB | 104MB | 129MB | 131MB |
12回目 | 104MB | 105MB | 131MB | 133MB |
やはりnilを代入してもメモリは消費されるようです。Bool?にtrue、Uint8?に10を代入してもメモリ消費量に変化はなかったためnilを代入した時点でT分のメモリも確保されているようです。
2バイト*1M個なので2MBずつ上がるかと予想していましたが所々メモリ消費量が急上昇していたり2MB以下の上昇量だったりしています。
この辺りは配列処理やメモリ管理系で別に動いているものがあるのかも知れませんね。