第6章
静的型付けの考え方
型がコンパイルのときに決まり、検査されるとはどういうことかを、Python との違いを実際に動かしながら学びます。型推論、引数の型、暗黙の変換をしない理由、型とメモリの関係、ジェネリクスの入り口までを見て、第II部を締めくくります。
この章の内容
第3章から第5章では、数値・文字列・コレクションといった 値の種類 を一つずつ見てきました。この章では視点を引いて、それらすべての土台になっている考え方、静的型付け を扱います。
Python を書いてきた人にとって、Mojo でいちばん最初に戸惑うのは、おそらく型まわりのエラーです。「Python なら動くのに、なぜ止められるのか」。その理由を、実際のエラーメッセージを読みながら、一つずつ解きほぐしていきます。文法の細かい話(変数の宣言、関数の書き方、トレイト)は後の章に譲り、ここでは 考え方 に絞ります。
この章を終えると、次のことができるようになります。
- 「静的に型が決まる」とはどういうことかを、Python と比べて説明できる
- 型推論がどこまで働き、どこから型を書く必要があるかが分かる
- Int と Float64 を混ぜたときなど、型のエラーを読んで直せる
- 型が値の大きさと使える操作を決めることを説明できる
- 型を引数にするコードの入り口(ジェネリクス)を見て、後の章へのつながりをつかめる
6.1「静的」とは何か
プログラミング言語の型の扱いは、大きく二つに分かれます。
- 動的型付け:型は 値 が持っていて、プログラムを動かしている最中に確かめる。Python がこちらです。
- 静的型付け:型は 変数や式 に結びついていて、プログラムを動かす前(コンパイルのとき)に確かめる。Mojo がこちらです。
違いを体験するために、同じ意味のプログラムを Python と Mojo で書いてみましょう。どちらも、else の中に型の間違いが一つ入っています。ただし ok は常に True なので、その行は実行されません。
ok = True
if ok:
print("fine")
else:
n: int = "5"
print(n)~$ python3 lazy.py fine
fine と表示されて、正常に終わりました。Python は、実行が else の中に入らない限り、その行を調べません。間違いはそこにあるのに、気づかれないままです。
では Mojo ではどうでしょうか。
def main():
var ok = True
if ok:
print("fine")
else:
var n: Int = "5"
print(n)~$ 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 では、それが コンパイルの時点 で、必ず見つかります。
6.2型推論:書かなくても型は決まっている
「静的に型が決まる」と聞くと、すべての変数に型を書かなければならないように思えます。実際には、右辺から型が分かる場合は書かなくて構いません。これを 型推論 と呼びます。
def main():
var x = 1
print(x)
x = "hello"
print(x)var x = 1 と書いただけで、x は Int(整数)の変数になります。この後で文字列を入れようとするとどうなるでしょう。
~$ 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 を使うと、式の型の名前を表示できます。
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())~$ 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 は、引数の型を 必ず書く ことを求めます。
def double(x) -> Int:
return x * 2
def main():
print(double(21))Python なら、def double(x): はごくふつうの書き方です。
~$ 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 の場所です。
なぜ書かせるのでしょうか。理由は二つあります。
- 呼び出す側と作る側の約束になる:
def add(a: Int, b: Int) -> Intと書いてあれば、この関数に何を渡せて、何が返ってくるのかが、本体を読まなくても分かります。 - 関数を単独でコンパイルできる:型が分かっていれば、呼び出される前に関数の機械語を作れます。呼び出しごとに型を探りながら動く必要がありません。
型を書くと、渡す側の間違いも見つかります。
def add(a: Int, b: Int) -> Int:
return a + b
def main():
print(add(1, 2))
print(add(1, "2"))~$ 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 の型ヒントで書くとどうなるでしょうか。
def double(x: int) -> int:
return x * 2
print(double(21))
print(double("ab"))~$ 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 ではどうでしょうか。
def main():
var count = 3
var rate = Float64(2.5)
var total = count * rate
print(total)count は Int、rate は Float64 です。
~$ 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 にそろえるのかで、結果が変わるからです。どちらにするかは、書く人が決めるべきだ、という考え方です。
直すには、明示的に変換 します。型の名前を関数のように使います。
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")~$ mojo run convert.mojo 7.5 2 3 items
Float64(count) で整数を小数にして、掛け算をしています。結果は 7.5 です。Int(y) は小数を整数にします。表示は 2 で、小数部分は切り捨てられました。String(count) は整数を文字列にします。文字列と整数を + でつなぐことはできませんが、String(count) で文字列にしたものとなら、つなげます。
def main():
var n = 3
var f: Float64 = n
print(f)同じ理由で、Int の変数を Float64 の変数に代入することもできません。
~$ 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") は エラーを起こしうる 関数として扱われ、呼び出す側にそれを宣言させます。
def main() raises:
var n = Int("42")
print(n + 1)
var f = Float64("2.5")
print(f * 2)~$ 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型が大きさと操作を決める
型は、検査のためだけのラベルではありません。値がメモリ上でどれだけの場所を使うか、そして その値に何ができるか を決めます。
大きさは、size_of で確かめられます。
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)~$ 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 が一度に処理しやすくなります。
では、範囲を超えるとどうなるでしょうか。
def main():
var a = Int8(100)
var b = Int8(100)
print(a + b)
var u = UInt8(5)
print(u - 10)~$ mojo run wrap.mojo -56 251
100 + 100 は200のはずですが、Int8 は127までなので、-56 になりました。UInt8 は符号なしの整数で 0 から 255 です。5 - 10 は 251 に回り込みました。エラーにはならず、静かに回り込みます。型の大きさを選ぶことは、表せる値の範囲を選ぶことでもあるのです。普段は Int で十分ですが、ピクセルの値を1バイトで持つような場面で、小さな型を選びます。
型は、使える操作 も決めます。
def main():
var name = "Mojo"
print(name.upper())
var n = 5
print(n.upper())~$ 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 では、型そのものを 引数 として受け取れます。これを ジェネリクス と呼びます。角かっこ [ ] の中に、型の引数を書きます。
def show[T: Writable](x: T):
print("value:", x)
def main():
show(42)
show("hello")
show(3.5)~$ 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 が同じなら、同じ型でなければなりません。
def same[T: Writable](a: T, b: T):
print(a, b)
def main():
same(1, 2)
same("a", "b")
same(1, "b")~$ 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静的型付けが役立つ場面
最後に、静的な型が実際にどんな場面で役立つのかを整理します。
書き換えの安全さ。 プログラムを後から変えるとき、型は「この変更は、どこに影響するのか」を教えてくれます。たとえば、関数の戻り値の型を変えてみましょう。
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 に変えました。
~$ 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 は、その手間と引き換えに、安全さと速さを手に入れる設計になっています。
| Python | Mojo | |
|---|---|---|
| 型を持つもの | 値 | 変数・式 |
| 型の検査 | 実行中(その行に届いたとき) | 実行の前(プログラム全体) |
| 変数に別の型を代入 | できる | エラー |
| 関数の引数の型 | 書かなくてよい | 必ず書く |
Int と Float64 の混在 | 自動で変換される | 明示的に変換する |
| 型ヒントの扱い | 実行時は無視 | 検査される |
6.9まとめ
第II部の最後に、この4章を振り返っておきましょう。
- 第3章の数値、第4章の文字列、第5章のコレクションは、それぞれ 決まった型 を持っています。その型が、値の大きさと、使える操作を決めています。
- 静的型付けでは、型がコンパイルのときに決まり、プログラム全体が実行の前に検査されます。間違いが一つでもあれば、何も実行されません。
- 変数の型は、右辺から 推論 されます。ただし関数の引数は、推論の手がかりがないので、型を書く必要があります。
- 一度決まった変数の型は変わりません。違う型の値を入れるのはエラーです。
IntとFloat64、IntとStringのような、型の違う値どうしは自動では変換されません。Float64(x)のように明示的に変換します。cannot implicitly convertが出たら、これを疑います。- Python の型ヒントは実行時には無視されますが、Mojo の型は必ず検査されます。
def show[T: Writable](x: T)のように、型を引数にして、複数の型で同じコードを使えます(ジェネリクス)。詳しくは第12章と第15章で扱います。
次の第III部では、これらの型の値を使って、変数・条件分岐・くり返し・エラー処理といった、プログラムの流れを組み立てる文法を学びます。
確認クイズ
Python と Mojo で、
elseの中に型の間違いがあり、その行が実行されないプログラムはどうなりますか?解答を見る
Python はその行を実行しないので、エラーにならずに動きます。Mojo は実行の前にプログラム全体を検査するので、実行される・されないにかかわらず、コンパイルエラーで止まります。
var x = 1のあとでx = "hello"と書くと、なぜエラーになりますか?解答を見る
var x = 1でxがIntの変数だと推論されるからです。変数の型は一度決まると変わらないので、文字列は代入できません。def double(x) -> Int:はなぜエラーになりますか?解答を見る
関数の引数には型を書く必要があるからです。引数には右辺がないので型を推論できず、
argument type must be specifiedというエラーになります。Intの変数とFloat64の変数を掛け算するには、どうしますか?解答を見る
どちらかを明示的に変換します。たとえば
Float64(count) * rateのように書きます。Mojo は型の違う値を自動では変換しません。Int8(100) + Int8(100)は何になりますか? それはなぜですか?解答を見る
-56になります。Int8は1バイトで -128 から 127 までしか表せないので、200 は範囲を超えて回り込みます。型の大きさが、表せる値の範囲を決めています。
練習問題 型のエラーを直す
次のプログラムは、型のエラーで実行できません。エラーメッセージを読み、1か所を直して、15.0 と表示されるようにしてください。
def main():
var price: Int = 100
var rate = Float64(0.15)
var tax = price * rate
print(tax)解答例を見る
price は Int、rate は Float64 なので、掛け算の前にどちらかを変換します。小数にそろえるのが自然です。
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") が両方動くようにします。
解答例を見る
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 になります。
def main():
print(UInt8(250) + UInt8(10))実行すると 4 と表示されます。