第10章 値・所有権・ライフサイクル
10.1 この章で学ぶこと
この章で学ぶ内容は以下のとおりです。
学ぶこと |
ポイント |
|---|---|
引数修飾子の選び方 |
|
所有権がアセンブリに現れる様子 |
|
ライフタイムとライフサイクルの違い |
参照の有効期間 vs 値全体の一生 |
Rust との設計の差 |
|
10.2 Values:値とメモリ配置
Mojo の値はスタックまたはヒープに置かれます。 どちらに置かれるかはコンパイル時に確定し、実行時に切り替わることはありません。
型の特性 |
配置 |
例 |
|---|---|---|
サイズが固定・小さい |
スタック(大半の |
|
サイズが可変または大きい |
ヒープ(ポインタ経由) |
|
参照カウント対象 |
ヒープ( |
複数の所有者が必要な場合 |
def main():
var n: Int = 40
var xs: List[Int] = [n, n + 1, n + 2]
print(n, len(xs))
リスト10-1: values_int_and_list.mojo
各変数のメモリ配置を追います。
var n: Int = 40 は 8 バイトの整数をスタック上に確保します。
コンパイル時にサイズが確定しているため、関数プロローグ(subq $..., %rsp)で main() 全体の領域がまとめて確保されます。
実行時の動的確保は一切発生しません。
var xs: List[Int] = [n, n + 1, n + 2] では xs 自体はスタック上のヘッダ(ポインタ、長さ、キャパシティの 3 フィールド)として配置されますが、要素 3 個分(24 バイト)の配列データはヒープ上に確保されます。
xs がスコープを抜けると List のデストラクタがヒープ領域を自動解放します。
「ヘッダはスタック、データはヒープ」というパターンは String も同じ設計です。
固定サイズのヘッダをスタックに置くことで、関数呼び出しのオーバーヘッドを最小にしながら可変長データを扱えます。
図10-1: スタック配置(Int・Counter・Float64)とヒープ配置(List[Int]・String・ArcPointer[T])の対比
左側がスタック配置、右側がヒープ配置です。スタック上の変数はアドレス(0x10(%rsp) 等)が関数プロローグで一括確保されます。List[Int] と String はヘッダ(ptr、length、capacity)のみスタックに置き、実データはヒープに確保します。ArcPointer[T] は参照カウント(RC=1)付きでヒープ上のオブジェクトを共有します。
10.3 Value semantics:値の受け渡し
Mojo は value semantics(値セマンティクス)が基本です。 変数は「オブジェクトへの参照」ではなく「値そのもの」を保持します。 代入すれば独立したコピーが生まれ、一方を変えてももう一方には影響しません。
Python |
Mojo |
|
|---|---|---|
|
同じオブジェクトを指すポインタのコピー |
論理的に独立した値(実際はムーブで最適化) |
関数に渡す |
参照を共有(呼び出し先が変更できる) |
デフォルトは |
変更したい場合 |
そのまま可能(副作用が暗黙) |
|
struct Counter:
var n: Int
def __init__(out self, start: Int):
self.n = start
def get(self) -> Int:
return self.n
def bump(mut self):
self.n += 1
def show(c: Counter):
print(c.get())
def main():
var c = Counter(0)
show(c)
c.bump()
show(c)
リスト10-2: value_semantics_counter.mojo
コードの各部分を追います。
def show(c: Counter): の c には引数修飾子がありません。
省略時は imm(読み取り専用の借用)として扱われます。
show の中で c.bump() のような変更メソッドを呼ぼうとするとコンパイルエラーになります。
呼び出し元の c はそのまま保たれます。
c.bump() は mut self を持つメソッドです。
c のアドレスをメソッドに渡して n を直接書き換えます。
最初の show(c) は 0、c.bump() の後の show(c) は 1 を表示します。
Python では show(c) 内で c.n = 99 のような変更が可能です(引数が参照共有のため)。
Mojo では imm で渡された値への変更はコンパイル時に拒否されます。
「この関数が引数を変更するかどうか」がシグネチャを見るだけで判断できます。
図10-2: Python(参照セマンティクス)と Mojo(値セマンティクス)の比較
左側の Python では y = x で同じオブジェクトを共有するため、y.value = 99 が x.value にも影響します。右側の Mojo では var y = x で独立したコピー(またはムーブ)が生まれ、y.bump() は x に影響しません。関数呼び出しも imm(読み取り専用借用)がデフォルトで、変更したい場合は mut を明示します。
10.4 Ownership:引数修飾子の使い分け
10.4.1 4 つの修飾子
引数の受け渡し方を制御する 4 つの修飾子をまとめます。
修飾子 |
意味 |
元の値への影響 |
C++ での類比 |
|---|---|---|---|
|
読み取り専用の借用 |
変わらない |
|
|
変更可能な借用 |
変わる(直接書き換え) |
|
|
ローカルコピーを持つ |
変わらない(コピーに操作) |
|
|
借用を受け取る |
|
|
「C++ での類比」は C++ の引数渡し規約に対応したアナロジーです。
表中の f は「任意の関数名」を表す仮置きの識別子です。
C++ の const T& は C++ 固有の記法で、const(変更不可)と &(参照)を意味します。
10.4.2 フローチャート:どれを選ぶか
実際のコードを書くとき、「どの修飾子を使えばよいか」は 2 つの問いに答えるだけで決まります。 「呼び出し元の値を変更するか」と「所有権を引き継ぐか」です。
図10-3: 引数修飾子の選び方フローチャート
10.4.3 imm / mut / var の実例
3 つの修飾子が 1 つのファイルに凝縮されたサンプルです。
imm による借用、ref によるインプレース書き換え、^ による所有権転送を順に確認します。
def sum_borrow(imm xs: List[Int]) -> Int:
var s = 0
for x in xs:
s += x
return s
def take_owned(var xs: List[Int]):
print(String("took len="), len(xs))
def main():
var xs: List[Int] = [1, 2, 3]
print(sum_borrow(xs))
for ref x in xs:
x *= 10
print(sum_borrow(xs))
var data: List[Int] = [7, 8]
take_owned(data^)
リスト10-3: ownership_read_ref_and_move.mojo
コードの箇所 |
何が起きているか |
|---|---|
|
|
|
|
|
|
コードの動きを順に追います。
sum_borrow(imm xs):imm xs は xs を読み取り専用で借用します。
xs の所有権は main のままです。
List 全体をコピーするコストがなく、元のデータを安全に参照できます。
関数内で xs.append(...) のような変更を試みるとコンパイルエラーになります。
1 回目の呼び出しは 6(1+2+3)を返します。
for ref x in xs: x *= 10:ref x は xs[i] のアドレスへのエイリアスです。
x *= 10 は元のリストを直接書き換えます。
このループ後、xs は [10, 20, 30] になります。
2 回目の sum_borrow(xs) は 60 を返します。
take_owned(data^):^ は所有権転送演算子(transfer operator)です。
data の所有権を take_owned に渡します。
この行以降 data を参照するとコンパイルエラーになります。
受け取った var xs は take_owned のローカル変数として扱われ、関数を抜けるとデストラクタが走ってヒープが解放されます。
呼び出し元で free を書く必要はありません。
10.5 アセンブリで見る lifecycle
Mojo の「スコープを抜けたら自動で解放される」という動作は、ソースコードの書き方とは無関係にコンパイラが __deinit__ 呼び出しを挿入することで実現されています。
このセクションでは実際に生成されるアセンブリを見て、その仕組みを確認します。
lifecycle_resource_traces.mojo のソースを確認してからアセンブリを読みます。
struct Resource:
var id: Int
def __init__(out self, id: Int):
self.id = id
print("init", id)
def __deinit__(deinit self):
print("del", self.id)
def main():
var r = Resource(1)
print(r.id)
リスト10-4: lifecycle_resource_traces.mojo
このコードのアセンブリを main() の先頭から末尾まで読みます。
実際のアセンブリ出力は標準ライブラリの実装を含め数百行になりますが、ここでは main() に相当する箇所を抜粋します。
STEP 1:スタック確保とコンストラクタ呼び出し(var r = Resource(1))
; def main():
590: subq $0x78, %rsp ; スタックフレーム確保(120 バイト)
; var r = Resource(1)
594: movl $0x1, %edi ; 引数 id = 1 をセット
599: leaq 0x10(%rsp), %rsi ; self(r)のスタック上アドレスをセット
59e: callq "_lifecycle_resource_traces::Resource::__init__(::SIMD[::DType(int), ::SIMDLength(1)])" ; ← コンストラクタ呼び出し
r はスタックの 0x10(%rsp) に直接置かれます。
ヒープ確保はありません。
コンストラクタのシンボル名にモジュール名プレフィックス(_lifecycle_resource_traces::)が付き、Int の実体である SIMD[DType.index, 1] がそのまま展開された形で表示されているのが 1.0 での見た目の違いです(Int は SIMD のエイリアスであることが、デマングル結果により忠実に反映されています)。
STEP 2:print(r.id) の実行
; print(r.id)
5a5: leaq _static_string_a8d4ace0dc8d360e(%rip), %rax ; sep(空文字)の先頭アドレス
5ac: movq %rax, 0x38(%rsp)
5b1: movq $0x1, 0x40(%rsp)
5ba: movq $0x1, 0x48(%rsp)
5c3: movq %rax, 0x18(%rsp)
5c8: movq %rax, 0x20(%rsp)
5cd: movq %rax, 0x50(%rsp)
5d2: movq %rax, 0x58(%rsp)
5d7: movq $0x1, 0x60(%rsp) ; ← ここまでで sep の StringSpan をスタック上に組み立てる
5e2: leaq _static_string_bbe01a6a523daf15(%rip), %rax ; end(改行)の先頭アドレス
5e9: movq %rax, 0x38(%rsp)
5ee: movq $0x1, 0x40(%rsp)
5f7: movq $0x1, 0x48(%rsp)
600: leaq _static_string_bbe01a6a523daf15(%rip), %rax
607: movq %rax, 0x28(%rsp)
60c: leaq _static_string_bbe01a6a523daf15(%rip), %rax
613: movq %rax, 0x30(%rsp)
618: leaq _static_string_bbe01a6a523daf15(%rip), %rax
61f: movq %rax, 0x50(%rsp)
624: leaq _static_string_bbe01a6a523daf15(%rip), %rax
62b: movq %rax, 0x68(%rsp)
630: movq $0x1, 0x70(%rsp) ; ← ここまでで end の StringSpan をスタック上に組み立てる
639: movq 0x10(%rsp), %rdi ; r.id(Int)を第 1 引数にロード
63e: movq %rsp, %rax
641: movq $0x1, (%rax) ; 引数カウント = 1
648: leaq _static_string_a8d4ace0dc8d360e(%rip), %rsi ; sep
64f: leaq _static_string_bbe01a6a523daf15(%rip), %rcx ; end
656: movl $0x1, %r8d
65c: xorl %r9d, %r9d
65f: movq %r8, %rdx
662: callq "_std::io::io::print[..., Int]" ; ← print 実行
文字列リテラルは .rodata の静的データへの参照であり、ヒープ確保も refcount 管理も発生しません。
ただし 1.0 では sep(区切り文字)と end(末尾文字)が単なるポインタではなく、長さやフラグを含む StringSpan 構造体としてスタック上に明示的に組み立てられてから print に渡されるようになりました。
そのため命令数は増えていますが、「静的データへの参照を組み立てて渡す」という骨格自体は変わっていません。
STEP 3:__deinit__ の自動挿入(r のスコープ終了)
; r のスコープが終わる(main の末尾)
667: leaq 0x10(%rsp), %rdi ; r のアドレスを第 1 引数にセット
66c: callq "_lifecycle_resource_traces::Resource::__deinit__(lifecycle_resource_traces::Resource$)" ; ← デストラクタ(自動挿入)
STEP 4:スタックフレーム解放と return
; def main():
671: addq $0x78, %rsp ; スタックフレームを解放(STEP 1 の逆)
675: retq ; return
ソースコードに r.__deinit__() は存在しません。
コンパイラが r のスコープ終了を検出して 0x66c に挿入しています。
シンボル名末尾の $ は Mojo の “consuming” 呼び出し(所有権を消費する呼び出し)を表します。
図10-4: アセンブリで見る lifecycle(STEP 1〜4 の全体像)
コラム: RAII(資源の寿命を型に結びつける)
RAII(Resource Acquisition Is Initialization)は、C++ が発祥のパターンです。 「資源の取得(open/alloc など)をコンストラクタに、解放(close/free など)をデストラクタに書く」という設計です。
Mojo の __init__ / __deinit__ も同じ発想です。
スコープを抜けた瞬間にコンパイラが __deinit__ を呼ぶため、次の利点があります。
解放忘れが起きない
例外や早期
returnがあっても確実に解放される
Python の with ブロック(__enter__ / __exit__)は同じ問題を解決しますが、「使う前に with を書く」というユーザーの義務があります。
RAII は型のスコープと解放が自動的に対応します。
10.6 Lifetimes:参照の有効期間
ライフタイムは参照が安全に使える期間です。
imm xs で借用した参照は、元の xs が生きている間だけ使えます。
xs が解放された後も参照を使い続けようとすること(ダングリング参照)は、Mojo の設計上はコンパイル時に検出して拒否されます(静的検査の成熟度については後述します)。
def first_len(imm xs: List[Int]) -> Int:
return len(xs)
def main():
var buf: List[Int] = [1, 2, 3, 4]
print(first_len(buf))
リスト10-5: lifetimes_read_param.mojo
first_len(imm xs: List[Int]) の imm xs は、xs の所有権を受け取らず参照のみを借用します。
関数内で xs のデータを参照できますが、xs は呼び出し元が所有し続けます。
関数が返ると借用も終わります。
ダングリング参照の例を示します。
# 概念的な例:Mojo の設計では NG
def get_ref_bad() -> ref [MutAnyOrigin] Int:
var x: Int = 42
return x # x はこの関数のスコープで消滅する → ダングリング参照
[MutAnyOrigin] は「任意の可変 origin」を意味します。
x は get_ref_bad のスタックフレームに存在するローカル変数で、関数が返るとスタックフレームごと消滅します。
Mojo の設計では、origin がこの関数のスコープを越えられないことをコンパイラが検出してエラーにします。
返った先の呼び出し元が受け取る参照は存在しないメモリを指すことになるためです。
注釈
Mojo 1.0.0 ではダングリング参照の静的チェックが未完全で、上記のコードはコンパイルエラーにならないケースがあります。将来バージョンで完全に実装される予定です。
規則 |
内容 |
|---|---|
参照の有効期間 |
参照する元の値が生きている間だけ使える |
ダングリング参照 |
解放された値を指す参照。Mojo は設計上コンパイル時に拒否する(1.0.0 では静的検査が未完成) |
|
Mojo がライフタイムを追跡するための内部概念。Rust の lifetime parameter に相当 |
コラム: Rust のライフタイムとの違い
Rust では fn f<'a>(x: &'a T) -> &'a T のように、ライフタイム名('a)を明示的に書く必要があります。
Mojo では同じ概念を Origin として内部で扱い、多くの場合ユーザーが明示しなくても推論されます(1.0 時点では一部の複雑なケースで明示が必要)。
これは Mojo が「Python に近い書きやすさ」と「所有権の安全性」を両立するための設計的な選択です。 Swift の ARC(自動参照カウント)よりは明示的で、Rust より緩やかな中間地点を目指しています。
ダングリング参照にならない 2 つのパターンを示します。
パターン 1:引数の origin を受け継ぐ(最も慣用的)
def first(ref list: List[Int]) -> ref [origin_of(list[0])] Int:
return list[0] # list は呼び出し元が所有 → スコープを越えられる
def main():
var xs: List[Int] = [1, 2, 3]
var r = first(xs) # r は xs が生きている間だけ有効
print(r) # 1
リスト10-6: ref_return_origin.mojo
list は呼び出し元(main)が所有するため、main が終わるまで生きています。
ref [origin_of(list[0])] と書くことで「この参照の寿命は list の寿命と同じ」とコンパイラに伝えます。
注釈
Mojo 1.0 では Interior Origins が導入され、List・Dict・String などのコンテナが返す要素への参照は、コンテナ全体の origin ではなく要素固有の origin(interior origin)として追跡されるようになりました。
そのため origin_of(list)(コンテナ全体)ではなく origin_of(list[0])(要素)と書く必要があります。
これにより、要素への参照を保持したままコンテナを変更するコード(例:参照を握ったまま list.append(...) する)をコンパイラがより正確に検出できるようになりました。
パターン 2:所有権を転送して返す
List[T] はヒープ上にデータを保持します。
^ 演算子で所有権ごと呼び出し元に転送すれば、スタックスコープに縛られず安全に返せます。
def make_heap_int() -> List[Int]:
var data = List[Int]()
data.append(42)
return data^ # 所有権ごと呼び出し元に転送
def main():
var data = make_heap_int()
print(data[0]) # 42
リスト10-7: move_transfer.mojo
data^ の ^ は所有権の転送(move)を示します。
List[Int] はヒープバッファを内部に持ち、そのバッファの所有権が呼び出し元に移ります。
関数のスタックフレームが消えても、ヒープ上のデータは呼び出し元が所有するため安全です。
パターン |
有効期間 |
返す型 |
|---|---|---|
ローカル変数を返す |
関数終了で死ぬ → NG |
— |
引数の origin を受け継ぐ |
呼び出し元のスコープ |
|
所有権転送 |
呼び出し元のスコープが終わるまで |
|
図10-5: Lifetimes:参照の有効期間と Origin の役割まとめ
10.7 Lifecycle:値の一生
10.6 節で扱ったライフタイムとこの節で扱うライフサイクルは、見た目が似ていますが対象がまったく異なります。
ライフタイム(Lifetime) は「参照がいつまで安全に使えるか」という話です。
Origin が追跡し、参照元の値が消えた後も参照を使おうとするとコンパイルエラーになります。
参照自体はデータを持たず、別の値を指しているだけです。
ライフサイクル(Lifecycle) は「値そのものがどう生まれて、どう消えるか」という話です。
__init__ で生まれ、使われ、スコープを抜けると __deinit__ が呼ばれて消えます。
値がリソース(ヒープ、ファイルハンドルなど)を持つ場合、その確保と解放がここに集約されます。
図10-7: Lifecycle:値の一生(誕生・使用・解放とライフタイムとの関係)
参照(p)のライフタイムは、参照元(r)のライフサイクルの中に収まらなければなりません。これがライフタイムとライフサイクルの関係です。
ライフタイム |
ライフサイクル |
|
|---|---|---|
対象 |
参照(借用) |
値そのもの |
期間 |
参照元が生きている間 |
|
問題 |
ダングリング参照 |
リソースリーク・二重解放 |
解決策 |
|
|
関連する修飾子 |
|
|
10.8 Life:コピーとムーブの入り口
コピーとムーブの動作
値を別の変数に渡すとき、Mojo は「コピー」と「ムーブ」の 2 つの方法を区別します。
コピー はデータの複製を作ります。 元の変数も新しい変数も、それぞれ独立したデータを持ちます。
var b = a.copy()
a [data: "hello"] ← そのまま残る
b [data: "hello"] ← 新しいコピー(独立)
ムーブ は所有権を転送します。 元の変数は消滅し、データはコピーされません。
var b = a^
a (消滅)
b [data: "hello"] ← a が持っていたデータをそのまま引き継ぐ
コピーはデータを丸ごと複製するコストがかかります。 ムーブは「誰が所有者か」という情報だけを更新するため、データサイズに関わらず低コストです。
図10-6: Life:コピーとムーブの違い
10.8.1 コピーの実装:Copyable と __copyinit__
Copyable trait を実装した型は a.copy() でコピーを作れます。
@fieldwise_init と組み合わせると __init__ と __copyinit__ が自動生成されます。
@fieldwise_init
struct Label(Copyable):
var text: String
def main():
var a = Label("hi")
var b = a.copy()
print(a.text, b.text)
リスト10-8: life_copyable_label.mojo
a.copy() を呼ぶと __copyinit__ が実行され、a とは独立した b が作られます。
a も b も main() を抜けるまで生き続け、それぞれのスコープ終了時に __deinit__ が呼ばれます。
__copyinit__ が呼ばれる主なタイミングは次の 2 つです。
a.copy()を明示的に呼ぶときvar引数で関数に渡すとき(value semantics による暗黙コピー)
10.8.2 ムーブの実装:Movable と ^
Movable trait を実装した型は ^ 演算子(transfer operator)で所有権を転送できます。
@fieldwise_init
struct Buffer(Movable):
var size: Int
def __deinit__(deinit self):
print("del", self.size)
def main():
var a = Buffer(size=3)
print("a.size =", a.size)
var b = a^ # a の所有権が b に移る(a は消滅)
print("b.size =", b.size)
# a.size ← コンパイルエラー: a はすでに消滅している
リスト10-9: life_movable_buffer.mojo
var b = a^ で a の所有権が b に移ります。
この時点で a は消滅し、以降 a にアクセスするとコンパイルエラー(use of uninitialized value 'a')になります。
__deinit__ はムーブ先の b が main() を抜けるときに 1 回だけ呼ばれます。
a の __deinit__ は呼ばれません。
@fieldwise_init と Movable を組み合わせると、__moveinit__(ムーブコンストラクタ)が自動生成されます。
これは各フィールドを順にムーブして新しいインスタンスを初期化します。
操作 |
元の変数 |
コスト・trait |
|---|---|---|
コピー( |
そのまま残る |
データ複製 / |
ムーブ( |
消滅(使用不可) |
低コスト / |
Copyable のみの型は ^ でムーブできません(コンパイルエラー)。
Movable のみの型は a.copy() でコピーできません。
両方を実装した型はどちらの操作も使えます。
10.9 Initialization:コンストラクタ設計
__init__ は型の初期状態を確定する唯一の場所です。
この節では、__init__ を手動で書くことで「型が常に正しい状態でしか存在できない」ように設計する方法を示します。
@fieldwise_init と手動 __init__ の違い
@fieldwise_init はフィールドをそのまま引数に取る __init__ を自動生成します。
手軽ですが、呼び出し元がすべてのフィールドを自由に指定できるため、意図しない状態を外から作られるリスクがあります。
@fieldwise_init
struct IntRange:
var lo: Int
var hi: Int
def main():
# 以下は両方コンパイルが通る
var ok = IntRange(lo=8, hi=12) # lo <= hi → 正常
var bad = IntRange(lo=12, hi=8) # lo > hi → 不正な状態
print(ok.lo, ok.hi)
print(bad.lo, bad.hi)
リスト10-10: fieldwise_init_intrange.mojo
IntRange が「整数の区間」を表す型なら、lo <= hi という条件(不変条件、invariant)は常に満たされるべきです。
@fieldwise_init ではこれを強制できません。
コンストラクタで不変条件を保証する
手動で __init__ を書き、呼び出し元から意味のある引数だけを受け取るよう設計します。
struct IntRange:
var lo: Int
var hi: Int
def __init__(out self, center: Int, width: Int):
self.lo = center - width // 2
self.hi = center + width // 2
def main():
var r = IntRange(10, 4)
print(r.lo, r.hi)
リスト10-11: init_box_bounds.mojo
center(中心値)と width(幅)だけを受け取り、lo と hi は内部で計算します。
lo = center - width // 2hi = center + width // 2
center=10, width=4 を渡すと lo=8, hi=12(出力 8 12)になります。
lo <= hi は計算上必ず成立するため、外から逆転した区間を作る手段がありません。
lo と hi のフィールドを直接指定するコンストラクタが存在しないので、「型の外から不正な状態を作れない」構造になっています。
このパターンを不変条件を持つ型と呼びます。 呼び出し元が「値が正しいか確認してから使う」のではなく、「型が正しい状態しか作れない」ように設計することで、バグの混入口をコンストラクタ 1 箇所に集約します。
警告
Mojo 1.0.0 の制限:フィールドのアクセス制御がない
Mojo 1.0.0 には private などのフィールドアクセス修飾子がありません。
var フィールドはすべて外から直接書き込めるため、v.lo = 99 のようにすると不変条件を壊せてしまいます。
__init__ による設計は「正しくない状態のインスタンスを 生成できなく する」効果はありますが、生成後のフィールド直接書き換えまでは防げません。
フィールドを完全に隠蔽するアクセス制御は将来のバージョンで導入される予定です。
現状はフィールド名にアンダースコア(_lo, _hi)を付けて「直接触らない」という慣習で対処することになります。
バリデーション付き __init__
raises を追加すると、不正な入力をコンストラクタで弾けます。
def __init__(out self, center: Int, width: Int) raises:
if width < 0:
raise Error("width must be non-negative")
self.lo = center - width // 2
self.hi = center + width // 2
こうすると width < 0 の場合は構築自体が失敗し、不正な IntRange が存在する余地がなくなります。
図10-8: Initialization:コンストラクタ設計と不変条件
10.10 Death:デストラクタとヒープ解放
__deinit__ はスコープを抜けるときにコンパイラが自動で呼ぶデストラクタです。
この節では、ヒープ上にデータを持つ型を RAII パターンで安全に管理する実例を通じて、__deinit__ の役割と二重解放防止の仕組みを示します。
HeapInts の設計
HeapInts はヒープ上に Int の配列を確保する型です。
コンストラクタで確保し、デストラクタで解放します。
各フィールドと操作の役割を先に整理します。
フィールド / メソッド |
役割 |
|---|---|
|
ヒープ上の Int 配列を指す生ポインタ |
|
配列の要素数 |
|
|
|
|
|
ポインタが指す場所に値 |
|
ポインタが指す要素を破棄( |
|
ヒープ領域をOSに返却 |
注釈
Mojo 1.0 では UnsafePointer 型は Pointer 型に統合されました。
「安全でない操作」は型そのものではなく、unsafe_offset・unsafe_write・unsafe_deinit_pointee・unsafe_free のように メソッド名の unsafe_ 接頭辞で明示する設計になっています。
alloc[T](n) も同様に非推奨となり、本書では移行策として提供されている unsafe_alloc[T](n)(std.memory.alloc からインポート)を使います。
from std.memory.alloc import unsafe_alloc
struct HeapInts:
var data: Pointer[Int, MutUntrackedOrigin]
var size: Int
def __init__(out self, *values: Int):
self.size = len(values)
self.data = unsafe_alloc[Int](self.size)
for i in range(self.size):
self.data.unsafe_offset(i).unsafe_write(values[i])
def __deinit__(deinit self):
for i in range(self.size):
self.data.unsafe_offset(i).unsafe_deinit_pointee()
self.data.unsafe_free()
def at(self, i: Int) -> Int:
return self.data.unsafe_offset(i)[]
def main():
var h = HeapInts(1, 2, 3)
print(h.at(0), h.at(2))
リスト10-12: death_heap_buffer.mojo
HeapInts(1, 2, 3) を構築すると __init__ が unsafe_alloc[Int](3) でヒープを確保し、各スロットに unsafe_offset(i).unsafe_write(v) で値を書き込みます。
h.at(0) は self.data.unsafe_offset(i)[] で i 番目の要素を読み出します。
main() を抜けて h のスコープが終わると __deinit__ が走り、各要素を unsafe_deinit_pointee() で破棄してから unsafe_free() でヒープを返却します。
deinit self の意味
__deinit__(deinit self) の deinit は「このメソッドが返った後、self は消滅する」という宣言です。
__deinit__(inout self) とは書きません。
コンパイラは __deinit__ 呼び出し後に self を参照するコードを許可しません。
二重解放を防ぐ仕組み
ヒープ解放で最も危険なバグの 1 つが二重解放(double free)です。
同じメモリを 2 回 free すると、ヒープが壊れて未定義動作を引き起こします。
Mojo は 2 つの規則でこれを構造的に防ぎます。
^(ムーブ)後は元の変数が使えない:所有権を転送した変数にアクセスするとコンパイルエラー(use of uninitialized value)になります。__deinit__はムーブ先が 1 回だけ呼ぶ:値がムーブされた場合、__deinit__を呼ぶ責任はムーブ先に移ります。ムーブ元の__deinit__は呼ばれないため、同じバッファが 2 回freeされることはありません。
Python の __del__ との違い
Python の __del__ は GC が呼ぶため「いつ呼ばれるかわからない」問題があります。
ファイルハンドルやソケットを保持する型でも、__del__ がいつ閉じるか不明なため、with 文で明示管理するのが慣例です。
Mojo では __deinit__ はスコープ終了時に確定的に呼ばれます。
ヒープバッファを持つ型を書く場合、__deinit__ に解放処理を書くだけで、呼び出し元に解放の責任を押しつけずに済みます。
図10-9: Death:デストラクタとヒープ解放
10.11 まとめ
本章で扱った内容を以下にまとめます。
要素 |
押さえるべきこと |
|---|---|
|
読み取りのみ。元の値は変わらない。デフォルト |
|
変更可能な借用。元の値を直接書き換える |
|
コピーまたはムーブして受け取る。独立した所有者 |
|
所有権を渡し切る。この後元の変数は使えない |
ライフタイム |
参照が安全に使える期間。コンパイラが Origin で追跡 |
ライフサイクル |
値全体の一生。 |
|
|