Mojo 入門

第6章

静的型付けの考え方

型がコンパイルのときに決まり、検査されるとはどういうことかを、Python との違いを実際に動かしながら学びます。型推論、引数の型、暗黙の変換をしない理由、型とメモリの関係、ジェネリクスの入り口までを見て、第II部を締めくくります。

この章の内容
  1. 6.1「静的」とは何か
  2. 6.2型推論:書かなくても型は決まっている
  3. 6.3関数の引数には型を書く
  4. 6.4Python の型ヒントは強制されない
  5. 6.5暗黙の変換はしない
  6. 6.6型が大きさと操作を決める
  7. 6.7ジェネリクス:型を引数にする最初の一歩
  8. 6.8静的型付けが役立つ場面
  9. 6.9まとめ

第3章から第5章では、数値・文字列・コレクションといった 値の種類 を一つずつ見てきました。この章では視点を引いて、それらすべての土台になっている考え方、静的型付け を扱います。

Python を書いてきた人にとって、Mojo でいちばん最初に戸惑うのは、おそらく型まわりのエラーです。「Python なら動くのに、なぜ止められるのか」。その理由を、実際のエラーメッセージを読みながら、一つずつ解きほぐしていきます。文法の細かい話(変数の宣言、関数の書き方、トレイト)は後の章に譲り、ここでは 考え方 に絞ります。

この章を終えると、次のことができるようになります。

6.1「静的」とは何か

プログラミング言語の型の扱いは、大きく二つに分かれます。

違いを体験するために、同じ意味のプログラムを Python と Mojo で書いてみましょう。どちらも、else の中に型の間違いが一つ入っています。ただし ok は常に True なので、その行は実行されません。

lazy.py
ok = True
if ok:
    print("fine")
else:
    n: int = "5"
    print(n)
Ubuntu
~$ python3 lazy.py
fine

fine と表示されて、正常に終わりました。Python は、実行が else の中に入らない限り、その行を調べません。間違いはそこにあるのに、気づかれないままです。

では Mojo ではどうでしょうか。

lazy.mojo
def main():
    var ok = True
    if ok:
        print("fine")
    else:
        var n: Int = "5"
        print(n)
Ubuntu
~$ mojo run lazy.mojo
/home/user/lazy.mojo:6:22: error: cannot implicitly convert 'StringLiteral["5"]' value to 'Int'
        var n: Int = "5"
                     ^~~
mojo: error: failed to parse the provided Mojo source module

fine は表示されず、エラーで止まりました。メッセージを読みましょう。lazy.mojo:6:22 は6行目の22文字目で、文字列 "5" を Int に 暗黙には変換できない と言っています。実行が else に入るかどうかは関係ありません。Mojo は実行を始める前にプログラム全体を調べ、型の食い違いを見つけたら、何も実行せずに止まります。

型の間違いが見つかるタイミング:Python は実行中、Mojo は実行の前 Python 型の間違いは、その行に実行が届いたときに初めて分かる 1 行目を実行 2 行目を実行 3 行目を実行 4 行目で型エラー 1〜3 行目は実行済み Mojo 型の間違いは、実行を始める前にまとめて見つかる 全体の型を検査 間違いがあれば:4 行目で型エラー 何も実行せずに止まる 間違いがなければ:プログラムを実行 どちらも「型の間違い」は同じ。違うのは、見つかる時点と、そのときまでに何が起きているかです。
図6-1 型の間違いが見つかるタイミングの違い

図のとおり、見つかる間違いは同じでも、見つかる時点が違います。Python では、その行に実行が届くまで分かりません。たとえば、本番だけで通る分岐の中に間違いがあると、何か月もたってから突然エラーになることがあります。Mojo では、それが コンパイルの時点 で、必ず見つかります。

6.2型推論:書かなくても型は決まっている

「静的に型が決まる」と聞くと、すべての変数に型を書かなければならないように思えます。実際には、右辺から型が分かる場合は書かなくて構いません。これを 型推論 と呼びます。

infer.mojo
def main():
    var x = 1
    print(x)
    x = "hello"
    print(x)

var x = 1 と書いただけで、x は Int(整数)の変数になります。この後で文字列を入れようとするとどうなるでしょう。

Ubuntu
~$ mojo run infer.mojo
/home/user/infer.mojo:4:9: error: cannot implicitly convert 'StringLiteral["hello"]' value to 'Int'
    x = "hello"
        ^~~~~~~
mojo: error: failed to parse the provided Mojo source module

4行目の x = "hello" が問題だと、場所まで正確に指しています。x は最初の代入で Int と決まったので、StringLiteral["hello"]("hello" という文字列リテラルの型)の値は入れられない、というわけです。

Python では、x = 1 のあとに x = "hello" と書いても問題ありません。名前 x は、そのときどきの値にくっつくラベルにすぎないからです。Mojo では、変数そのものが型を持ちます。一度決まった型は、その変数が存在する間、変わりません。

型推論の結果は、実際に確かめられます。reflect を使うと、式の型の名前を表示できます。

names.mojo
from std.reflection import reflect

def main():
    var n = 10
    var f = 2.5
    var s = "text"
    var b = True
    print(n, f, s, b)
    print(reflect[type_of(n)].name())
    print(reflect[type_of(f)].name())
    print(reflect[type_of(s)].name())
    print(reflect[type_of(b)].name())
Ubuntu
~$ mojo run names.mojo
10 2.5 text True
SIMD[DType.int, 1]
SIMD[DType.float64, 1]
String
Bool

最初の行は4つの値そのものの表示です。続く4行が型の名前で、type_of(n) は変数 n の型を返し、reflect[...].name() がその名前を文字列にします。整数は SIMD[DType.int, 1]、小数は SIMD[DType.float64, 1] と表示されました。Int や Float64 は、実は「要素が1個の SIMD ベクトル」の別名なのです。SIMD は第3章で入り口を見て、第20章で本格的に扱います。ここでは、Int の型名が Int と表示されるとは限らないことだけ覚えておいてください。この先のエラーメッセージで Int と書かれているのは、読みやすいように別名で表示されているものです。

6.3関数の引数には型を書く

型推論が働くのは、右辺から型が分かる場所です。関数の引数には右辺がないので、推論の手がかりがありません。そこで Mojo は、引数の型を 必ず書く ことを求めます。

noanno.mojo
def double(x) -> Int:
    return x * 2

def main():
    print(double(21))

Python なら、def double(x): はごくふつうの書き方です。

Ubuntu
~$ mojo run noanno.mojo
/home/user/noanno.mojo:1:12: error: argument type must be specified
def double(x) -> Int:
           ^
mojo: error: failed to parse the provided Mojo source module

argument type must be specified、つまり「引数の型を指定しなければならない」と言っています。位置は1行目12文字目、(x) の x の場所です。

なぜ書かせるのでしょうか。理由は二つあります。

型を書くと、渡す側の間違いも見つかります。

add.mojo
def add(a: Int, b: Int) -> Int:
    return a + b

def main():
    print(add(1, 2))
    print(add(1, "2"))
Ubuntu
~$ mojo run add.mojo
/home/user/add.mojo:6:18: error: invalid call to 'add': value passed to 'b' cannot be converted from 'StringLiteral["2"]' to 'Int'
    print(add(1, "2"))
          ~~~    ^~~
/home/user/add.mojo:1:5: note: function declared here
def add(a: Int, b: Int) -> Int:
    ^
mojo: error: failed to parse the provided Mojo source module

6行目の "2" が、引数 b の型 Int に変換できないと言っています。エラーメッセージの最後の note: function declared here は、呼び出した関数の宣言を指しています。どの約束に違反したのかが、一目で分かります。しかも、5行目の add(1, 2) は問題ないのに、何も表示されません。第1章で見たとおり、エラーがあればプログラム全体が動かないのです。

-> Int の部分も同じです。戻り値の型を書いておくと、return した値がその型に合っているかが検査されます。

6.4Python の型ヒントは強制されない

前の節で見た add を、Python の型ヒントで書くとどうなるでしょうか。

hint.py
def double(x: int) -> int:
    return x * 2

print(double(21))
print(double("ab"))
Ubuntu
~$ python3 hint.py
42
abab

エラーは出ません。double("ab") は x が str なのに、そのまま動いて abab を返しました。型ヒントの int は、人間とエディターへの ヒント にすぎず、Python の実行時には使われないのです。しかも -> int と書いたのに、返ってきたのは文字列です。

Mojo では、同じ書き方が 約束 になります。

Python の型ヒントMojo の型
書かなくてもよいかよい関数の引数は必須
違反したとき何も起きない(別ツールで検査)コンパイルエラー
いつ分かるか実行時、または mypy を実行したときプログラムを動かす前
実行時に使われるかほぼ使われない型に合わせた機械語が作られる

6.5暗黙の変換はしない

型が静的に決まっていると、「違う型どうしの計算」をどう扱うかが問題になります。Python では 1 + 2.5 が 3.5 になるように、数値どうしならおおむね自動で通じます。Mojo ではどうでしょうか。

mix.mojo
def main():
    var count = 3
    var rate = Float64(2.5)
    var total = count * rate
    print(total)

count は Int、rate は Float64 です。

Ubuntu
~$ mojo run mix.mojo
/home/user/mix.mojo:4:25: error: invalid call to '__mul__': value passed to 'rhs' cannot be converted from 'Float64' to 'Int'
    var total = count * rate
                ~~~~~~~~^~~~
Mojo/stdlib/std/simd.mojo:1130:1: note: function declared here
def __mul__(self, rhs: Self) -> Self    # note - synthetic signature
^
mojo: error: failed to parse the provided Mojo source module

count * rate で、rate(Float64)を Int に変換できないと言われました。Mojo は、Int と Float64 を 勝手に合わせてくれません。Int を Float64 にそろえるのか、Float64 の小数部分を捨てて Int にそろえるのかで、結果が変わるからです。どちらにするかは、書く人が決めるべきだ、という考え方です。

直すには、明示的に変換 します。型の名前を関数のように使います。

convert.mojo
def main():
    var count = 3
    var rate = Float64(2.5)
    var total = Float64(count) * rate
    print(total)

    var y = 2.5
    print(Int(y))
    print(String(count) + " items")
Ubuntu
~$ mojo run convert.mojo
7.5
2
3 items

Float64(count) で整数を小数にして、掛け算をしています。結果は 7.5 です。Int(y) は小数を整数にします。表示は 2 で、小数部分は切り捨てられました。String(count) は整数を文字列にします。文字列と整数を + でつなぐことはできませんが、String(count) で文字列にしたものとなら、つなげます。

assign.mojo
def main():
    var n = 3
    var f: Float64 = n
    print(f)

同じ理由で、Int の変数を Float64 の変数に代入することもできません。

Ubuntu
~$ mojo run assign.mojo
/home/user/assign.mojo:3:22: error: cannot implicitly convert 'Int' value to 'Float64'
    var f: Float64 = n
                     ^
mojo: error: failed to parse the provided Mojo source module

代入のときも「暗黙には変換できない」と言われました。cannot implicitly convert(暗黙には変換できない)は、Mojo の型のエラーでもっとも多く目にするメッセージです。見たら、「どちらかを明示的に変換してほしいのだな」と読みます。

文字列を数値にするのも、明示的な変換です。ただしこちらは、失敗することがあります("abc" は整数にならない)。そのため、Int("42") は エラーを起こしうる 関数として扱われ、呼び出す側にそれを宣言させます。

parse.mojo
def main() raises:
    var n = Int("42")
    print(n + 1)
    var f = Float64("2.5")
    print(f * 2)
Ubuntu
~$ mojo run parse.mojo
43
5.0

main() に raises を付けたので、動きました。n + 1 は 43、f * 2 は 5.0 です。raises が何なのか、try でどう受け止めるのかは、第9章で学びます。ここでは、「失敗するかもしれない変換は、型の約束の中にエラーの可能性まで含まれる」ということだけ受け取ってください。raises を付け忘れると、cannot call function that may raise in a context that cannot raise というエラーになります。

6.6型が大きさと操作を決める

型は、検査のためだけのラベルではありません。値がメモリ上でどれだけの場所を使うか、そして その値に何ができるか を決めます。

型が値のメモリ上の大きさと使える操作を決める Int 8 バイト(64 ビット) 表せる値 整数。最大値は 9223372036854775807 使える操作 + - * // 小数の足し算は不可 文字列のメソッドも不可 Int8 1 バイト(8 ビット) 表せる値 小さな整数。最大値は 127 使える操作 + - * // 範囲を超えると回り込む 100 + 100 は -56 になる Float64 8 バイト(64 ビット) 表せる値 小数(浮動小数点数) 2.5 0.1 1e-9 使える操作 + - * / Int とは別の型なので 混ぜるときは変換が必要
図6-2 Int・Int8・Float64 の大きさと使える操作

大きさは、size_of で確かめられます。

size.mojo
from std.sys import size_of

def main():
    print("Int    :", size_of[Int](), "bytes")
    print("Int8   :", size_of[Int8](), "bytes")
    print("Int32  :", size_of[Int32](), "bytes")
    print("Float64:", size_of[Float64](), "bytes")
    print("Float32:", size_of[Float32](), "bytes")
    print("Bool   :", size_of[Bool](), "bytes")
    print("Int max:", Int.MAX)
    print("Int8 max:", Int8.MAX)
Ubuntu
~$ mojo run size.mojo
Int    : 8 bytes
Int8   : 1 bytes
Int32  : 4 bytes
Float64: 8 bytes
Float32: 4 bytes
Bool   : 1 bytes
Int max: 9223372036854775807
Int8 max: 127

Int は8バイト、Int8 は1バイト、Int32 は4バイトです。名前の数字はビット数で、8ビットが1バイトに当たります。Float64 は8バイト、Float32 は4バイトです。Int の最大値は 9223372036854775807 で、約 922 京にもなります。Int8 は 127 までしか表せません。

Python の int は、桁数が増えれば大きくなる整数なので、計算でオーバーフローしません。そのかわり、1つの整数が使うバイト数は決まっておらず、大きな数ほどメモリも手間も増えます。Mojo の Int は 8バイトで固定 です。固定だからこそ、配列に並べたときにすき間なく詰められ、CPU が一度に処理しやすくなります。

では、範囲を超えるとどうなるでしょうか。

wrap.mojo
def main():
    var a = Int8(100)
    var b = Int8(100)
    print(a + b)
    var u = UInt8(5)
    print(u - 10)
Ubuntu
~$ mojo run wrap.mojo
-56
251

100 + 100 は200のはずですが、Int8 は127までなので、-56 になりました。UInt8 は符号なしの整数で 0 から 255 です。5 - 10 は 251 に回り込みました。エラーにはならず、静かに回り込みます。型の大きさを選ぶことは、表せる値の範囲を選ぶことでもあるのです。普段は Int で十分ですが、ピクセルの値を1バイトで持つような場面で、小さな型を選びます。

型は、使える操作 も決めます。

ops.mojo
def main():
    var name = "Mojo"
    print(name.upper())
    var n = 5
    print(n.upper())
Ubuntu
~$ mojo run ops.mojo
/home/user/ops.mojo:5:12: error: 'Int' value has no attribute 'upper'
    print(n.upper())
          ~^
mojo: error: failed to parse the provided Mojo source module

文字列には upper() があり、整数にはありません。エラーは 'Int' value has no attribute 'upper' です。Python でも、n = 5 のあとの n.upper() は AttributeError になりますが、それは実行がその行に届いたときです。Mojo は、どの型にどのメソッドがあるかを、コンパイルのときにすべて知っている ので、実行前に見つけられます。エディターの補完候補も、型から正確に出せます。

6.7ジェネリクス:型を引数にする最初の一歩

ここまでは、型を具体的に書いてきました。ところが、「どんな型でも受け付けるが、型は決まっていてほしい」という関数を書きたいこともあります。たとえば、値を表示するだけの関数です。Int 用、String 用、Float64 用と、別々に書くのは大変です。

Mojo では、型そのものを 引数 として受け取れます。これを ジェネリクス と呼びます。角かっこ [ ] の中に、型の引数を書きます。

generic.mojo
def show[T: Writable](x: T):
    print("value:", x)

def main():
    show(42)
    show("hello")
    show(3.5)
Ubuntu
~$ mojo run generic.mojo
value: 42
value: hello
value: 3.5

show[T: Writable](x: T) は、「T という型を受け取る。T は Writable(print で表示できる)という条件を満たす型でなければならない。引数 x の型はその T」という意味です。呼ぶときは show(42) と書くだけで、T は引数から 推論 されます。整数、文字列、小数のそれぞれに合わせた別々の関数が作られ、コンパイルされます。

Python の関数も、どんな型でも受け付けます。しかし違いがあります。Mojo のジェネリクスでは、T が同じなら、同じ型でなければなりません。

generic_bad.mojo
def same[T: Writable](a: T, b: T):
    print(a, b)

def main():
    same(1, 2)
    same("a", "b")
    same(1, "b")
Ubuntu
~$ mojo run generic_bad.mojo
/home/user/generic_bad.mojo:7:13: error: invalid call to 'same': value passed to 'b' cannot be converted from 'StringLiteral["b"]' to 'Int'
    same(1, "b")
    ~~~~    ^~~
/home/user/generic_bad.mojo:1:5: note: function declared here
def same[T: Writable](a: T, b: T):
    ^
mojo: error: failed to parse the provided Mojo source module

same(1, "b") では、最初の引数 1 から T が Int に決まったので、2つ目の "b" を Int に変換できないと言われました。T は「何でもよい」ではなく、「関数を呼ぶたびに一つに決まる型」だからです。same(1, 2) と same("a", "b") はそれぞれ通ります。

なお T: Writable の : Writable を省くことはできません。def show[T](x: T) と書くと、parameters must always have a type というエラーになります。型の引数にも、「どんな種類のものか」を示す約束(トレイト)が必要だからです。

6.8静的型付けが役立つ場面

最後に、静的な型が実際にどんな場面で役立つのかを整理します。

書き換えの安全さ。 プログラムを後から変えるとき、型は「この変更は、どこに影響するのか」を教えてくれます。たとえば、関数の戻り値の型を変えてみましょう。

refactor.mojo
def average(a: Int, b: Int) -> Float64:
    return Float64(a + b) / 2

def main():
    var r: Int = average(3, 4)
    print(r)

もともと average は Int を返していて、main もそれを Int で受けていたとします。その後、小数を返す Float64 に変えました。

Ubuntu
~$ mojo run refactor.mojo
/home/user/refactor.mojo:5:25: error: cannot implicitly convert 'Float64' value to 'Int'
    var r: Int = average(3, 4)
                 ~~~~~~~^~~~~~
mojo: error: failed to parse the provided Mojo source module

呼び出し側の5行目が、Float64 を Int に代入できないと教えてくれました。Python なら、全部実行して確かめるか、テストが拾ってくれるのを期待するしかありません。Mojo では、関数を変えた瞬間に、影響を受ける呼び出しがすべてエラーとして一覧になります。

エディターの支援。 型が分かっているので、VS Code の Mojo 拡張は、保存する前から赤い波線でエラーを示し、補完候補も型から出せます。

速度。 型が決まっているので、コンパイラは Int の足し算を、CPU の足し算の命令1つに直せます。Python のように「これは何の型かな」と実行のたびに調べる必要がありません。これが Mojo の速さの土台です。速度を測って伸ばす方法は、第19章以降で学びます。

代わりに失うもの。 型を決めることで、Python のような「何でも入る変数」の気軽さは失われます。型を書く手間が増え、変換を明示する必要があります。それでも Mojo は、その手間と引き換えに、安全さと速さを手に入れる設計になっています。

PythonMojo
型を持つもの値変数・式
型の検査実行中(その行に届いたとき)実行の前(プログラム全体)
変数に別の型を代入できるエラー
関数の引数の型書かなくてよい必ず書く
Int と Float64 の混在自動で変換される明示的に変換する
型ヒントの扱い実行時は無視検査される

6.9まとめ

第II部の最後に、この4章を振り返っておきましょう。

次の第III部では、これらの型の値を使って、変数・条件分岐・くり返し・エラー処理といった、プログラムの流れを組み立てる文法を学びます。

確認クイズ

  1. Python と Mojo で、else の中に型の間違いがあり、その行が実行されないプログラムはどうなりますか?

    解答を見る

    Python はその行を実行しないので、エラーにならずに動きます。Mojo は実行の前にプログラム全体を検査するので、実行される・されないにかかわらず、コンパイルエラーで止まります。

  2. var x = 1 のあとで x = "hello" と書くと、なぜエラーになりますか?

    解答を見る

    var x = 1 で x が Int の変数だと推論されるからです。変数の型は一度決まると変わらないので、文字列は代入できません。

  3. def double(x) -> Int: はなぜエラーになりますか?

    解答を見る

    関数の引数には型を書く必要があるからです。引数には右辺がないので型を推論できず、argument type must be specified というエラーになります。

  4. Int の変数と Float64 の変数を掛け算するには、どうしますか?

    解答を見る

    どちらかを明示的に変換します。たとえば Float64(count) * rate のように書きます。Mojo は型の違う値を自動では変換しません。

  5. Int8(100) + Int8(100) は何になりますか? それはなぜですか?

    解答を見る

    -56 になります。Int8 は1バイトで -128 から 127 までしか表せないので、200 は範囲を超えて回り込みます。型の大きさが、表せる値の範囲を決めています。

練習問題 型のエラーを直す

次のプログラムは、型のエラーで実行できません。エラーメッセージを読み、1か所を直して、15.0 と表示されるようにしてください。

mojo
def main():
    var price: Int = 100
    var rate = Float64(0.15)
    var tax = price * rate
    print(tax)
解答例を見る

price は Int、rate は Float64 なので、掛け算の前にどちらかを変換します。小数にそろえるのが自然です。

mojo
def main():
    var price: Int = 100
    var rate = Float64(0.15)
    var tax = Float64(price) * rate
    print(tax)

実行すると 15.0 と表示されます。price を最初から Float64 として宣言する方法もあります。

練習問題 型を引数にした関数

同じ型の2つの値を受け取り、first: ... second: ... の形で表示する関数 pair を、ジェネリクスで書いてください。pair(1, 2) と pair("a", "b") が両方動くようにします。

解答例を見る
mojo
def pair[T: Writable](a: T, b: T):
    print("first:", a, "second:", b)

def main():
    pair(1, 2)
    pair("a", "b")

T が Writable を満たしていれば、print に渡せます。pair(1, "b") のように型が違うと、コンパイルエラーになります。

練習問題 回り込みを予想する

UInt8(250) + UInt8(10) はいくつになりますか? 実行する前に予想してから、確かめてください。

解答例を見る

UInt8 は 0 から 255 までなので、260 は 255 を超えます。256 を引いた 4 になります。

mojo
def main():
    print(UInt8(250) + UInt8(10))

実行すると 4 と表示されます。