Swiftのvarとletを科学する

こんにちは、やまひろ (@yamahiro248) です。 最近 iOSDC 2024 で 2 本プロポーザルを出し、ドキドキしています。

 

さて、今日は Swiftの基本中の基本であるvarとletについて、 ただの変数、定数という概念のその先の知識として基本をあえてお話をさせていただきます。

varとlet

Swiftにおいてvar、letはそれぞれ以下の意味を示します。

  • varは変更可能な変数
  • letは変更不可能な定数

この基本中の基本とも言える変数・定数はプログラム上でどう扱われているのかをあえて深掘りした話を進めます。

以下の進め方をします。

  1. サンプルコードを作成
  2. コンパイル
  3. nmコマンドを実行しオブジェクトを解析
  4. 解析結果からの考察
  5. コンパイラ
  6. 最後に

1. サンプルコードを作成

sample.swiftを作成します。

今回はvar、letを確認することを重きに置いているので、処理自体にそこまでの意味はありません。

import Foundation

class Person {
    var name: String
    var age: Int
    let address: String = "住所は変更できません"

    init(name: String, age: Int) {
        self.name = name
        self.age = age
    }

    func getYearsOlderThan(other: Person) -> Int {
        return self.age - other.age
    }

    func getYearsLessThan(other: Person) -> Int {
        return other.age - self.age
    }

    func getAgeDate() -> Date {
        return generateAgeDate()
    }
    
    private func generateAgeDate() -> Date {
        let calendar = Calendar.current
        let now = Date()
        guard let birthDate = calendar.date(byAdding: .year, value: -self.age, to: now) else {
            fatalError("Could not calculate birth date")
        }
        return birthDate
    }
}

var p1: Person = Person(name: "Alice", age: 20)
let p2: Person = Person(name: "Alice", age: 20)
var p3: Person = p2
let p4: Person = p3

print("p1.name : \(p1.name) , p1.age : \(p1.age)")
print("p2.name : \(p2.name) , p2.age : \(p2.age)")
print("p3.name : \(p3.name) , p3.age : \(p3.age)")

2. コンパイル

次に作成したサンプルコードをコンパイルします。

コンパイルには、swiftcのコマンドでコンパイルを行います。

※ -gのオプションをつけることでデバッグシンボルを含めた状態でコンパイルすることができます。

※ -oのオプションでは出力ファイルの指定を行う

% swiftc -g sample.swift -o output

ビルドが正常に行われると、outputというオブジェクトファイルができます。

nmコマンドでオプジェクトを解析

次にnmコマンドでこのオプジェクトファイルを解析します。

nmコマンドは、オブジェクトファイル等からシンボルテーブルの内容を表示するために使用します。

今回は、出力したoutputをnmコマンドで解析してみます。コマンドは以下の通りとなります。

nm output | xcrun swift-demangle > sample.demangle.elf.text

xcrunは、Xcodeのコマンドラインツールの一部で、Swiftコンパイラなどを使いやすくしたコマンドになります。

このコマンドにオプションのswift-demangleをつけることによって、変換されたシンボル名を元のSwiftコードでの名前に戻すことができます。 砕いて言うと、どこのコードだったかをわかりやすくするコマンドになります。

ここで、sample.demangle.elf.textというファイルを生成してその中にオブジェクトのシンボルテーブルを表示します。

4. 解析結果からの考察

シンボルテーブルから参照する主なシンボルは以下の通りです。

T:テキスト(コード)セクションに定義されたグローバル(外部から参照可能な)シンボル
t:テキスト(コード)セクションに定義されたローカル(外部から参照不可能な)シンボル
D:データセクションに定義されたグローバルシンボル
d:データセクションに定義されたローカルシンボル
B:BSSセクションに定義されたグローバルシンボル
b:BSSセクションに定義されたローカルシンボル
R : 読み取り専用データセクションにあるシンボル
U:未定義のシンボル

※ BSSセクション:Block Started by Symbolの略。未初期化のグローバル変数と静的変数を保持。

シンボルテーブルとは、上記のようにプログラム実行時にはそのメモリが確保される際にどのような配置で確保されるかを定義したテーブルです。

シンボルテーブルについてはこちらを参照してみると説明があります。

https://ja.wikipedia.org/wiki/%E3%82%B7%E3%83%B3%E3%83%9C%E3%83%AB%E3%83%86%E3%83%BC%E3%83%96%E3%83%AB

このシンボルテーブルを完全に理解したいと言う場合は、こちらの書籍もおすすめです。

https://shop.cqpub.co.jp/hanbai/books/38/38071.html

(シンボルテーブルを意識するのはプログラムをロードするタイミングで、どうやってプログラムが実行されているかを理解するのに非常に役立ちます。)

上記のシンボルを前提に、すると以下の結果となります。 ソースコードでは

var p1: Person = Person(name: "Alice", age: 20)
let p2: Person = Person(name: "Alice", age: 20)

としたので、p1はvar、p2はletになります。シンボルテーブルを見ると、以下の通りとなります。

(上略)
00000001000081d8 B output.p1 : output.Person
00000001000081e0 B output.p2 : output.Person
00000001000081e8 B output.p3 : output.Person
(下略)

となり、特にlet、varの区別はなくBSSセクションなので、letだろうがvarだろうがオブジェクトレベルではほとんど差分はないということになります。 メモリ上に扱われる上ではp1もp2も同じということになります。

さらに、クラス内のletを確認してみると

00000001000033d0 T output.Person.address.getter : Swift.String
0000000100003e60 R direct field offset for output.Person.address : Swift.String
00000001000033a4 T variable initialization expression of output.Person.address : Swift.String

となっており、一方varは

0000000100003ec8 T method descriptor for output.Person.name.modify : Swift.String
0000000100003158 T output.Person.name.getter : Swift.String
0000000100003eb8 T method descriptor for output.Person.name.getter : Swift.String
0000000100003e50 R direct field offset for output.Person.name : Swift.String
00000001000031b0 T output.Person.name.setter : Swift.String
0000000100003ec0 T method descriptor for output.Person.name.setter : Swift.String

となっており、基本的にsetterがあるかないかが大きな違いとなります。 つまり値をセットする時にsetterを作らない=let、setterを作る=varということで、 クラスのインスタンスを生成する時はヒープ領域に一般的にメモリは確保されますが、 このヒープ領域上のvar、letのデータそのものは同じで、setterがあるかないかの差分のみということになります。

そして実際は、コンパイル時にエラーが起きたりしてletへの書き込みをしているような処理は事前にコンパイルエラーとして弾かれてしまいます。

つまり、letはコンパイル時に定数であることを保証しているに過ぎないということです。

コンパイラ

コンパイラの挙動はhttps://www.swift.org/documentation/swift-compiler/に記載がありますが、以下の順に実行されます。

  1. Parsing:意味や型の情報を持たない抽象構文木(AST)を生成
  2. Semantic Analysis:解析されたASTを型チェックされたASTに変換し、意味的な問題に対し警告・エラーを出力
  3. Clang importer:Clangモジュールをインポートして、エクスポートするAPIをSwift APIにマッピング
  4. SIL generation:Swiftをさらに分析・最適化した中間言語に変換
  5. SIL guaranteed transformations:未初期化変数の使用などのプログラムの正しさに影響するデータフロー診断を実行
  6. SIL optimizations:ORCの最適化やSwift固有の高レベルの追加最適化を実行
  7. LLVM IR generation:マシンコードを生成

今回の話では詳細は含めませんが、letで宣言した変数は再代入が行われたかどうかを「2. Semantic Analysis」でチェックしています。

最後に

まとめると以下の通りとなります。

  • letもvarもオブジェクトファイルとしてはメモリ上としては同等と扱われる
  • クラスのvar、letも同様だが、setterがある、なしの差になる
  • letで再代入されないことをコンパイラの「Semantic Analysis」にてチェックし、コンパイルエラーとする
  • つまり、letとは再代入をコンパイラで禁止した変数を「定数」と呼んでいる

基本的な事柄を一つ考えただけでもオブジェクトレベルで解析すると本質の理解を進めることができます。 Swiftの基本を大事にコードの品質を考えるエンジニアがアンドパッドには多数在籍しています。

アンドパッドでは 基本的な事でも大事にしながら正しい知識を習得して成長したいiOSエンジニアを募集しています!

hrmos.co

engineer.andpad.co.jp

引用