第6章 概要・関数・変数

6.1 この章で学ぶこと

この章で学ぶ内容は以下のとおりです。

表6-1: 本章で学ぶこと

学ぶこと

ポイント

関数の書き方と読み方

def[] vs ()・オーバーロード・raises

変数とスコープ

var のブロックスコープと暗黙宣言の関数スコープの違い

アセンブリとの接続

var n: Int = 42movq $0x2a になる仕組み

所有権の入り口

^(transfer)で所有権を移動するパターン

6.2 概要:最初に押さえること

Mojo で最初に理解しておく基本的な考え方を整理します。

表6-2: Mojo の基本概念

ポイント

説明

エントリポイント

def main(): が起点。Python と同じ記法

ブロック

: とインデントで表す(Python と同じ)

変数

var または暗黙宣言で作る。一度決まった型は変えられない

関数定義

def が基本。fn は 1.0.0 で削除された

型と構造体

型のまとまりは struct で定義する

エラー

raises を付けた関数だけがエラーを返せる

6.2.1 最小サンプル

上記の基本概念を一度に確認できる最小構成のサンプルです。 型注釈付き変数宣言(var n: Int)、型推論(var doubled)、ジェネリックなリスト(List[Int])、文字列オブジェクト(String)という 4 種類の変数を宣言し、最後に print() で出力します。

def main():
    var n: Int = 42
    var doubled = n * 2
    var xs: List[Int] = [1, 2, 3]
    var label = String("count")
    print(String("n="), n, String(" sum="), doubled, String(" "), label, String("="), len(xs))

リスト6-1: language_basics_minimal.mojo

このコードは次章の逆アセンブル(第7章 コンパイル結果のアセンブリを読む)で詳細に追います。 ここでは「どんな要素が含まれるか」を概観しておきます。

表6-3: language_basics_minimal.mojo のコード要素

コードの要素

ポイント

var n: Int = 42

型注釈付き宣言。スタック上に 8 バイト(Int = 64 bit)を確保

var doubled = n * 2

右辺から型推論。コンパイラが n * 2 = 84 を即値として畳み込む

var xs: List[Int] = [1, 2, 3]

List の構築は 3 段階の関数呼び出しに展開される

var label = String("count")

文字列オブジェクトをスタック上に構築。データは静的シンボル

6.3 関数

6.3.1 deffn:今は def が基本

Mojo では関数の定義に def を使います。 以前は fn も使われていましたが、Mojo 1.0.0 で完全に削除されました。 deffn の違いと、削除に至った経緯を比較します。

表6-4: def と fn の比較

def(現在の基本)

fn(1.0.0 で削除)

型注釈

省略できる(推論が働く)

引数・戻り値ともに必須

raises なし

暗黙的に例外が伝播する

コンパイルエラー(明示が必要)

引数の既定

不変の読み取り専用参照(小さい値はレジスタ渡し)

同じ規約。mut / owned で所有権を細かく制御

状態

Mojo 1.0 で唯一の関数定義キーワード

Mojo 1.0.0 で完全に削除された

コラム: なぜ fn は削除されたか

初期の Mojo(〜0.9 系)では、fn が「システムプログラミング向けの厳格な関数」、def が「Python 互換の緩やかな関数」として共存していました。

しかし def が所有権、型推論、raises を正しく扱えるようになり、fn との差が実質的になくなりました。 2 つの関数定義キーワードを維持するコストが設計的に不合理なため、fn はまず非推奨になり、Mojo 1.0.0 で完全に削除されました。

Mojo 1.0.0 では fn を書くとコンパイルエラーになります。def のみが使えます。

6.3.2 ()[]:実行時 vs コンパイル時

()[] の使い分けは、Mojo の関数を読むうえで中心となる概念です。

Python では、関数に渡せる値はすべて「実行時の引数」です。 関数を呼び出す瞬間まで、どんな値が渡されるかコンパイラにはわかりません。

def repeat(msg, count):     # msg も count も実行時に決まる
    for _ in range(count):
        print(msg)

Mojo はこれを2種類に分けます。実行時に確定する値() に、コンパイル時に確定する値[] に渡します。

def repeat[count: Int](msg: String):   # count はコンパイル時、msg は実行時
    for _ in range(count):
        print(msg)

[] に渡した値はビルドの時点で確定するため、コンパイラはその値に特化した機械語を生成できます。 repeat[2](...) なら「2回繰り返す」専用のコードが作られ、実行時にループ回数を判定する必要がありません。 これを整理すると以下のとおりになります。

表6-5: ()[] の確定タイミング

記法

確定タイミング

意味

def foo(x: Int)

実行時(arguments)

呼び出し時に値が渡される。毎回異なる値が来てよい

def foo[N: Int]()

コンパイル時(parameters)

コンパイル時に値が確定。その値専用のコードが生成される

上記の repeat[count: Int] を含む、[]() の使い分けを示すサンプルコードです。 オーバーロード(show)、デフォルト引数(greet)、raisespick_nonzero)も合わせて確認できます。

def show(a: Int):
    print("int", a)


def show(s: String):
    print("str", s)


def repeat[count: Int](msg: String):
    for _ in range(count):
        print(msg)


def greet(name: String = "world"):
    print(String("Hello, "), name)


def pick_nonzero(x: Int) raises -> Int:
    if x == 0:
        raise Error("zero not allowed")
    return x


def main():
    show(1)
    show(String("hi"))
    repeat[2](String("Hello"))
    greet()
    greet(String("Mojo"))
    try:
        _ = pick_nonzero(0)
    except e:
        print("caught:", e)

リスト6-2: functions_overload_params.mojo

repeat[2](String("Hello"))[2] は実行時引数ではありません。 コンパイラは count = 2 と確定した状態でループを展開し、最適化できます。

6.3.3 オーバーロードとアセンブリ

show(a: Int)show(s: String) は同名でも 別の関数です。 コンパイラは引数の型から呼び出し先を選択し、異なるシンボルを生成します。 実際に、生成されたアセンブリのコードを見ると、それぞれ別の関数(ラベル)が呼び出されているのが分かります。

; show(1) の呼び出し
callq  "_show(::Int)"        ; Int 版のシンボル

; show(String("hi")) の呼び出し
callq  "_show(::String)"     ; String 版のシンボル

実行時に「どちらを呼ぶか」を判定するコードはありません。 コンパイル時に解決済みです。 これが Python の isinstance チェックとの違いです。

コラム: オーバーロード解決の規則

Mojo がオーバーロードを解決するときの優先順位は次のとおりです。

  1. 引数の型が完全に一致するもの

  2. 型変換(conversion)1 回で一致するもの

  3. 暗黙変換が必要なもの

あいまいな場合(どちらも同じ優先度で一致する)はコンパイルエラーになります。 Python のような「名前で上書き」ではなく、型を見た静的解決です。

6.3.4 例外

Mojo の例外は raise Error("メッセージ") で発生させ、try/except で受け取ります。 Python と構文は似ていますが、違いが 1 つあります。 エラーを返す可能性がある関数は、シグネチャに raises を明記しなければなりません。 raises がない関数から raise するとコンパイルエラーになります。

Python との違いをまとめます。

表6-6: Python と Mojo の例外機構の比較

Python

Mojo

エラーを投げる宣言

不要(どこでも raise できる)

raises をシグネチャに書く必要がある

呼び出し元の義務

なし(try/except は任意)

raises な関数を呼ぶなら、try/except か自分も raises が必要

コンパイル時の確認

なし

raises を忘れるとコンパイルエラー

def pick_nonzero(x: Int) raises -> Int:   # raises が必要
    if x == 0:
        raise Error("zero not allowed")
    return x

def main() raises:                         # main も raises が必要
    try:
        _ = pick_nonzero(0)
    except e:
        print("caught:", e)

リスト6-3: raises_try_except.mojo

6.4 変数

6.4.1 スタック上の変数レイアウト

var n: Int = 42 は、スタック上の特定アドレスに 8 バイトを確保します。第7章 コンパイル結果のアセンブリを読むで示すアセンブリ出力 language_basics_minimal.asm を見ると、次のように現れます。

; var n: Int = 42
7:   movq   $0x2a, 0x1e8(%rsp)    ; 0x2a = 42、スタック offset 0x1e8 に格納

; var doubled = n * 2  (コンパイル時に 84 に確定済み)
13:  movq   $0x54, 0x1f0(%rsp)    ; 0x54 = 84、スタック offset 0x1f0 に格納

スタック上の配置をイメージすると次のようになります。

スタック上の変数配置

図6-1: スタック上の変数配置

変数のオフセットはコンパイル時に確定しています。 実行時に「どこに置くか」を計算しません。

6.4.2 var のブロックスコープ

Python では if ブロック内で変数を作っても、その変数は関数全体で有効です(関数スコープ)。 Mojo の var はこれと異なり、宣言したブロックの内側でのみ有効です。 if を抜けると変数は消え、それ以降参照するとコンパイルエラーになります。

次のコードで、var x = 1if ブロック内にのみ存在することを確認できます。

def block_var():
    if True:
        var x = 1
        print("inside", x)
    # ここでは x は使えない(ブロックスコープ)


def main():
    block_var()

リスト6-4: variables_block_var.mojo

表6-7: Python と Mojo の変数スコープの比較

Python

Mojo var

スコープの単位

関数(if ブロック内でも関数外から見える)

ブロック(if の中で宣言した変数は外から見えない)

if True: x = 1 → 関数全体で x が有効

if True: var x = 1if を抜けると x は無効

Python エンジニアの注意点

ブロックで変数を作っても外で使える

var はブロックスコープ。外で使うなら外で宣言する

6.4.3 暗黙宣言(関数スコープ)

var なしの代入は 関数スコープになります。 Python に近い感覚で使えます。

def implicit_scope():
    y = 10
    if True:
        y = 20
    print("y after if:", y)


def main():
    implicit_scope()

リスト6-5: variables_implicit_scope.mojo

6.4.4 所有権の移動:^(transfer)

Mojo では、変数は値の「所有者」です。 ^(transfer 演算子)を付けて関数に渡すと、所有権ごと移動します。 移動後の変数は使えなくなり、コンパイラがこれを静的に検出します。 ^ がない場合は自動的にコピーが発生します。

所有権の仕組み全般(borrowedinoutowned 引数の違いや、__copyinit__/__moveinit__ の役割)は 第10章 値・所有権・ライフサイクル で詳しく扱います。 ここでは ^ を使うと「コピーせず渡せる」という入口だけ押さえておきます。

def take(var s: String):
    print(s)


def main():
    var msg = String("hi")
    take(msg^)

リスト6-6: variables_ownership_transfer.mojo

このコードで所有権がどう移動するかを追ってみます。

  1. var msg = String("hi")msgString オブジェクトの所有者になります。この時点でメモリ上に文字列が確保され、msg だけがその値を「持って」います。

  2. take(msg^)^ を付けて渡すと、msg の所有権ごと take に移動します。この行を過ぎると msg は無効になり、以降 msg を参照するとコンパイルエラーになります。コピーは起きません。

  3. def take(var s: String)::受け取った s が新しい所有者です。take の中では s を自由に読み書きできます。

  4. take が返ると s はスコープを外れ、String のメモリが解放されます。

^ を付けない take(msg) と書いた場合は、msg のコピーが作られて take に渡されます。 msg 自体はその後も有効です。 ^ は「このまま渡して、元の変数は捨てる」という明示的な意思表示です。

表6-8: 所有権移動のコード要素

コード

意味

var msg = String("hi")

msgString の所有者。参照カウント = 1

take(msg^)

^ で所有権を take に渡す。この行以降 msg は使えない

def take(var s: String):

var s で所有権を受け取る。take 内での変更が可能

^ がないとコピーが発生します。 ^ を付けると「コピーせずに移動」になります。 コピーコストが大きい型(大きな ListString)では ^ を意識すると無駄なメモリ確保を避けられます。

6.5 まとめ

本章で扱った内容を以下にまとめます。

表6-9: 本章まとめ

要素

押さえるべきこと

def / fn

def のみが使える。fn は Mojo 1.0.0 で削除された

() vs []

() = 実行時の引数、[] = コンパイル時のパラメータ

オーバーロード

同名関数を型で区別。コンパイル時に解決済みで実行時コストなし

raises

エラーを返す可能性をシグネチャで明示。呼び出し元も対応が必要

var スコープ

var はブロックスコープ。暗黙宣言は関数スコープ

^(transfer)

所有権を移動する。コピーなしに値を渡し切る

スタック配置

var n: Int = 42movq $0x2a, 0x1e8(%rsp) でコンパイル時にオフセット確定