第8章 型・演算子・制御・エラー
8.1 この章で学ぶこと
この章で学ぶ内容は以下のとおりです。
学ぶこと |
ポイント |
|---|---|
|
フィールドが同じでも名前が違えば別の型 |
SIMD 型とアセンブリ |
|
|
ビット XOR(二項演算子)と所有権転送(後置演算子) |
エラー処理 |
|
8.2 Types
8.2.1 struct と名義的型システム
Mojo では多くの型を struct で定義します。
設計上の特徴は 名義的(nominal) な型システムにあります。
# x, y を持つ struct が二つあっても、名前が違えば別の型(名義的)
@fieldwise_init
struct Point2D:
var x: Int
var y: Int
@fieldwise_init
struct PointXY:
var x: Int
var y: Int
def main():
var p = Point2D(10, 20)
var q = PointXY(10, 20)
print(p.x, p.y, q.x, q.y)
リスト8-1: types_struct_nominal.mojo
@fieldwise_init は、フィールドの宣言順に引数を取る __init__ を自動生成するデコレータです。
Point2D(10, 20) と書けば x=10, y=20 で初期化できます。
手書きのコンストラクタが不要になります。
Point2D と PointXY はどちらも x: Int と y: Int を持ちますが、別の型として扱われます。
次のような代入はコンパイルエラーになります。
var p = Point2D(10, 20)
var q: PointXY = p # エラー: cannot implicitly convert 'Point2D' to 'PointXY'
フィールドの形(構造)が同じでも、型の名前が違えば別の型です。 これが 名義的型システム(nominal type system)の意味です。
Python のダックタイピングや TypeScript の構造的型システムとは正反対の設計です。
Python では「x と y を持つオブジェクトなら何でもよい」という発想ですが、Mojo では「Point2D という名前の型でなければならない」と厳密に区別します。
名義的(Mojo・Rust・Go) |
構造的(TypeScript・Duck typing) |
|
|---|---|---|
型の区別 |
名前で決まる |
フィールドの形で決まる |
|
別の型(代入不可) |
同じ形なら同じ型 |
コンパイル時 |
型の間違いを確実に検出 |
予期しない「型が一致」が起こり得る |
実行時コスト |
なし(型情報はコンパイル時に消える) |
なし |
図8-1: 名義的型システム(Mojo・Rust・Go)と構造的型システム(TypeScript・Duck typing)の比較
名義的型システムの利点は、意図の明示です。
Point2D と PointXY が偶然同じフィールドを持っていても、それらを互いに代入できる設計上の理由はありません。
コンパイラが「混同しないように」強制することで、大規模なコードで型の取り違えが起きにくくなります。
コラム: Rust・Go との比較
Rust と Go も名義的型システムを採用しています。
struct Foo { x: i32 } と struct Bar { x: i32 } は Rust でも別の型です。
Go は例外的に、インターフェースだけは構造的型システムで判定します。 「メソッドを持っていれば実装したとみなす」という Go 独自の設計です。
Mojo は trait(後述)を使ってこの問題を解決しており、インターフェースの適合は明示的に宣言します。
8.2.2 組み込み型の一覧
Mojo の主な組み込み型を一覧で示します。
型 |
サイズ(x86) |
用途 |
|---|---|---|
|
8 バイト(64 bit) |
汎用整数。ポインタサイズと同じ |
|
8 バイト |
IEEE 754 倍精度浮動小数点 |
|
4 バイト |
単精度。GPU での行列演算によく使う |
|
1 バイト |
|
|
可変長(ヒープ) |
UTF-8 文字列。所有権管理対象 |
|
可変長(ヒープ) |
動的配列。要素型 |
各型について、Python との主な違いを補足します。
Int:固定サイズの整数
Python の int は任意精度(何桁でも扱える)ですが、Mojo の Int は 64 bit 固定です。
x86-64 ではポインタと同じサイズで、レジスタ 1 本に収まります。
1_000_000_000_000 のような大きな整数は扱えますが、数百桁の巨大な数は扱えません。
ビット幅を明示したい場合は Int8 / Int16 / Int32 / Int64(符号あり)や UInt8 / UInt16 / UInt32 / UInt64(符号なし)を使います。
GPU カーネルや低レベル処理でビット幅を制御するときに有用です。
Float64 / Float32:精度の選択
Python の float は常に IEEE 754 倍精度(64 bit) ですが、Mojo では Float32(単精度 32 bit)も明示的に選べます。
GPU の行列演算や機械学習の重みでは Float32 が標準で、メモリ使用量とスループットの両面で有利です。
String:所有権を持つ UTF-8 文字列
Python の文字列はイミュータブルで、インタープリタが内部でインターン(共有)します。
Mojo の String は ヒープ上にデータを持ち、所有権で管理されます。
第6・7章のアセンブリ出力で、文字列オブジェクトが「データポインタ、長さ、タグ、関数ポインタ 2 本」というレイアウトでスタック上に展開される様子を見たのがこの型です。
スコープを抜けると __deinit__ が呼ばれ、ヒープが返却されます。
Python の GC とは異なり、解放タイミングはコンパイル時に確定します。
List[T]:均一型の動的配列
Python の list はどんな型の値でも混在させられますが、Mojo の List[T] は 要素型 T をコンパイル時に確定させます。
List[Int] には Int しか入れられません。
これにより、要素アクセスのたびに型チェックが不要になり、C の配列に近いパフォーマンスが得られます。
8.2.3 SIMD 型とアセンブリ
SIMD は最もアセンブリに近いところで動く型です。
def main():
var v4 = SIMD[DType.float64, 4](1.0, 2.0, 3.0, 4.0)
var bumped = v4 + 10.0
var v1 = SIMD[DType.float64, 1](2.5)
print(bumped[0], bumped[3], v1[0])
リスト8-3: types_simd_basics.mojo
SIMD[DType.float64, 4] は 64 bit 浮動小数点を 4 つひとかたまりとして扱います。
このコードをコンパイルすると、加算 v4 + 10.0 が次のアセンブリになります。
; var bumped = v4 + 10.0
1d4: movapd 0x22144(%rip), %xmm0 ; [10.0, 10.0] を 128 bit レジスタに load
1dc: addpd %xmm0, %xmm2 ; xmm2 の上位 2 要素を同時加算
1e0: addpd %xmm0, %xmm1 ; xmm1 の下位 2 要素を同時加算
addpd(add packed double)は 1 命令で 2 つの Float64 を同時に加算します。
4 要素のために 2 命令で完結します。
方法 |
命令数(4 要素加算) |
命令 |
|---|---|---|
スカラーループ(Python/動的型) |
4 命令 |
|
|
2 命令 |
|
AVX 有効時 |
1 命令 |
|
図8-2: スカラー演算(4 命令)・SIMD/XMM(2 命令)・AVX/YMM(1 命令)の比較。右下に x86-64 と ARM NEON のレジスタ対応も示す。
コラム: SIMD と CPU レジスタの対応(x86-64)
x86-64 には浮動小数点演算専用のベクタレジスタがあります。
XMM レジスタ(128 bit):
Float64 × 2またはFloat32 × 4を格納できるYMM レジスタ(256 bit):
Float64 × 4またはFloat32 × 8(AVX が必要)ZMM レジスタ(512 bit):
Float64 × 8またはFloat32 × 16(AVX-512 が必要)
SIMD[DType.float64, 4] は 256 bit(Float64 × 4)なので、AVX がある CPU なら YMM レジスタ 1 本に収まります。
今回の .asm は SSE2 ベースライン(x86_64-apple-macosx15.0)なので、XMM 2 本+ addpd 2 命令になっています。
GPU では同じ概念がさらに大規模に展開されます(32 スレッドが SIMD で動く warp)。
コラム: ARM の SIMD(NEON)
Apple Silicon(M1/M2/M3)や多くのスマートフォン向け ARM プロセッサには、NEON(Advanced SIMD)というベクタ演算機構があります。
レジスタ構成
ARM は V0〜V31 の 32 本、各 128 bit のベクタレジスタを持ちます。 x86-64 の XMM レジスタに相当します。
V0.2D(128 bit):Float64 × 2を格納V0.4S(128 bit):Float32 × 4を格納
x86-64 との命令対応
操作 |
x86-64(SSE2) |
ARM(NEON) |
|---|---|---|
|
|
|
|
|
|
定数を全レジスタにセット |
|
|
SIMD[DType.float64, 4] の ARM での展開
NEON のレジスタ幅は 128 bit です。
Float64 × 4 は 256 bit なので、x86 の SSE2 と同様に V レジスタ 2 本+ fadd 2 命令に展開されます。
; ARM NEON での Float64 × 4 加算(概略)
fadd v0.2d, v0.2d, v2.2d ; 上位 2 要素
fadd v1.2d, v1.2d, v3.2d ; 下位 2 要素
コラム(続き): NEON の制約と Mojo の SIMD
x86 の AVX(256 bit YMM レジスタ)に相当する 256 bit NEON は存在しません。 ARM の NEON は 128 bit 固定です。 サーバー向けの ARM には可変長ベクタ命令 SVE(Scalable Vector Extension)がありますが、Apple Silicon は NEON のみです。
Mojo の SIMD 型はターゲット CPU に合わせてコンパイラが最適な命令を選択するため、同じソースコードを x86-64 と ARM の両方でコンパイルできます。
8.2.4 型変換は明示が基本
Python は int と float が混在する式を自動的に float に昇格させますが、Mojo はそれを行いません。
型変換が必要なときは、プログラマが明示的に書く必要があります。
コンパイラが「どこで変換コストが発生するか」を正確に把握できるようにするための設計です。
def main():
var n: Int = 7
var half = Float64(n) / 2.0
print(half)
リスト8-5: types_explicit_cast.mojo
Python の / は自動的に float 除算になりますが、Mojo では型が混在していると コンパイルエラーになります。
Float64(n) と明示することで変換が起きます。
これはコンパイラが「いつ変換コストが発生するか」を正確に把握するための設計です。
8.3 Operators
Mojo の算術、比較、論理演算子は Python とほぼ同じです。
+ - * / // % ** や == != < > and or not は動作も構文も変わりません。
この節では Mojo 独自の意味を持つ ^ に絞って説明します。
Python では ^ はビット XOR の記号ですが、Mojo ではそれに加えて所有権転送という全く異なる用途があります。
8.3.1 ^ の 2 つの意味
^ は書かれた位置によって意味が変わります。
2 つの値の間(二項演算子) として使えばビット XOR、値の直後(後置演算子) として使えば所有権転送です。
def consume_list(var xs: List[Int]):
print("len:", len(xs))
def main():
print("XOR:", 5 ^ 3)
var data: List[Int] = [1, 2, 3]
consume_list(data^)
var s = String("ha")
print("repeat:", s * 3)
リスト8-7: operators_xor_and_move.mojo
書き方 |
読み方 |
動作 |
|---|---|---|
|
ビット XOR |
|
|
transfer(所有権転送) |
|
同じ記号が 2 つの意味を持つのは見た目の紛らわしさがありますが、二項か後置かで明確に区別できます。
8.4 Control flow
Mojo の制御構文は Python の構文をほぼそのまま踏襲しています。
if / elif / else、while、break / continue、ループの else は動作も見た目も変わりません。
Python と異なる点は for ループの変数の扱いです。
通常の for x in list: はコピーを受け取るため元のリストは変わりませんが、for ref x in list: と書くと参照として受け取り、ループ内で書き換えると元のリストに反映されます。
8.4.1 if / for / while
構文は Python とほぼ同じです。
構文 |
Python との違い |
|---|---|
|
完全に同じ。 |
|
コピーでループする( |
|
参照でループする( |
|
完全に同じ |
|
完全に同じ |
ループの |
|
8.4.2 for ref :インプレース更新
次のコードは List[Int] の要素を走査し、奇数の要素を 1 減らして偶数にそろえます。
for ref v in values: で各要素の参照を取得し、条件に合う場合は v = v - 1 で直接書き換えています。
def main():
var values: List[Int] = [1, 4, 7, 3, 6, 11]
for ref v in values:
if v % 2 != 0:
v = v - 1
print("after evenize (len):", len(values))
リスト8-8: control_flow_for_ref_inplace.mojo
for ref v in values: の ref を忘れると、v はコピーになります。
元のリストを変更したいときは ref が必要です。
8.5 Errors
Mojo のエラー処理は Python の try / except / else / finally 構文を踏襲しています。
Python を知っていれば構文はほぼ読めますが、2 点が異なります。
raisesの明示:エラーを投げる可能性のある関数はシグネチャにraisesを書かなければなりません。Python のように「投げるかもしれない」が暗黙になることはありません。raise e^による再送出:エラーを捕捉した後に再送出するときはraise e^と書きます。^は所有権転送(8.3.1 参照)で、コピーを避けて安全に再送出します。
8.5.1 エラー処理のしくみ
Mojo のエラー処理のしくみを他の言語と比較しながら整理します。
要素 |
Python |
Mojo |
|---|---|---|
投げる |
|
|
受け取る |
|
同じ構文 |
成功時 |
|
同じ |
後片付け |
|
同じ |
再送出 |
|
|
8.5.2 コード例
process_record は id の値に応じて 2 種類のエラーを投げます。
id > 999 は「レコード不在」として内側の except でその場で処理し、id < 0 は「致命的なエラー」として raise e^ で外側の try ブロックへ再送出します。
else: は例外が起きなかったときだけ、finally: は常に実行されます。
def process_record(id: Int) raises -> String:
if id < 0:
raise Error("invalid record ID: must be non-negative")
if id > 999:
raise Error("record not found")
return String("record_") + String(id)
def main():
try:
var ids: List[Int] = [5, 0, 1001, -3, 42]
for i in range(len(ids)):
var id = ids[i]
var result: String
try:
print(String("try => id: "), id)
if id == 0:
continue
result = process_record(id)
except e:
if id < 0:
print("except => fatal, re-raise:", e)
raise e^
print("except => handled:", e)
else:
print(String("else => success: "), result)
finally:
print(String("finally => done with id: "), id)
except e:
print("outer caught:", e)
リスト8-10: errors_try_except_reraise.mojo
コードの箇所 |
何を示しているか |
|---|---|
|
この関数がエラーを返す可能性を明示 |
内側の |
|
|
エラーオブジェクトの所有権を転送して再送出。コピーなし |
|
|
|
成功・失敗を問わず必ず実行 |
外側の |
内側で処理しきれなかったエラーを最後に受け取る |
図8-3: Python と Mojo のエラー処理比較(上)と process_record の制御フロー(中)。id < 0 は raise e^ で外側へ再送出、id > 999 はその場で処理。
コラム: raise e^ の ^ が必要な理由
raise e と raise e^ の違いは所有権です。
raise e はエラーオブジェクトをコピーして再送出しようとします。
しかし Mojo のエラー型が Copyable でない場合、コンパイルエラーになります。
raise e^ は所有権を転送します。
コピーコストがなく、コンパイラも「e はこれ以降使えない」と確定できます。
再送出する場合は常に raise e^ が推奨されます。
8.6 まとめ
本章で扱った内容を以下にまとめます。
要素 |
押さえるべきこと |
|---|---|
|
フィールドが同じでも名前が違えば別の型。コンパイル時に確定 |
|
N 個の T 型をまとめた型。加算が |
型変換 |
暗黙変換に頼らず |
|
所有権転送。 |
|
コピーか参照か。インプレース更新には |
|
エラーの可能性をシグネチャで明示。再送出は |