Mojo 入門

第4章

文字列

Mojo の文字列は UTF-8 で保存されます。そのため len() も s[0] も Python のようには使えません。長さ・添字・スライス・メソッド・数値との変換・書式づくりを、実際のエラーを見ながら身につけます。

この章の内容
  1. 4.1文字列リテラルと String
  2. 4.2連結・くり返し・比較
  3. 4.3長さを数える:バイトか、文字か
  4. 4.4添字とスライス
  5. 4.51文字ずつ取り出す
  6. 4.6よく使うメソッド
  7. 4.7数値と文字列の変換
  8. 4.8文字列を組み立てる
  9. 4.9複数行の文字列とエスケープ
  10. 4.10Python との違いのまとめ
  11. 4.11まとめ

この章では、Mojo の文字列(String)の使い方を学びます。+ でつなぐ、メソッドを呼ぶ、数値と相互に変換するといった基本は Python とよく似ています。ところが、len(s) や s[0] はそのままでは書けません。理由は、Mojo の文字列が UTF-8 のバイト列として保存されているからです。その仕組みと、代わりに使う書き方を、日本語の文字列を使って確かめます。

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

4.1文字列リテラルと String

まず、文字列を作って表示する小さなプログラムから始めます。

basics.mojo
def main():
    var greeting = "Hello, Mojo!"
    var name: String = "World"
    print(greeting)
    print("Hello, " + name + "!")
Ubuntu
~$ mojo run basics.mojo
Hello, Mojo!
Hello, World!

"Hello, Mojo!" のように引用符で囲んだ部分を 文字列リテラル と呼びます。リテラルそのものは、コンパイルのときに内容が決まっている特別な型(StringLiteral)です。ところが、var greeting = "..." のように変数に入れると、String 型になります。String は、実行中に長さを変えたり、メソッドで加工したりできる、ふつうの文字列の型です。

型が本当に String になっているか、わざと間違えて確かめてみましょう。String の変数を Int の変数に入れてみます。

type_check.mojo
def main():
    var greeting = "Hello, Mojo!"
    var n: Int = greeting
    print(n)
Ubuntu
~$ mojo run type_check.mojo
/home/user/type_check.mojo:3:18: error: cannot implicitly convert 'String' value to 'Int'
    var n: Int = greeting
                 ^~~~~~~~
mojo: error: failed to parse the provided Mojo source module

メッセージに 'String' value とあるとおり、greeting は String です。普段はリテラルと String の違いを意識する必要はありません。意識するのは、リテラルをそのまま使うと、メソッドが足りない ことがあるときです。

literal_method.mojo
def main():
    print("hello".replace("l", "L"))
Ubuntu
~$ mojo run literal_method.mojo
/home/user/literal_method.mojo:2:18: error: 'StringLiteral["hello"]' value has no attribute 'replace'
    print("hello".replace("l", "L"))
          ~~~~~~~^
mojo: error: failed to parse the provided Mojo source module

'StringLiteral["hello"]' value has no attribute 'replace'(リテラルには replace がない)と言われました。String(...) で包むか、いったん var に入れれば String になって使えます。

literal_ok.mojo
def main():
    var word = "hello"
    print(word.replace("l", "L"))
    print(String("hello").replace("l", "L"))
Ubuntu
~$ mojo run literal_ok.mojo
heLLo
heLLo

4.2連結・くり返し・比較

Python と同じ演算子が使えます。+ で連結、* で整数回のくり返し、+= で追記、== や < で比較、in で部分文字列の検査です。

operators.mojo
def main():
    var a = String("abc")
    print(a + "def")
    print(a * 3)
    print("-" * 10)

    var b = String("ab")
    b += "cd"
    b += "ef"
    print(b)

    print("abc" == "abc", "abc" != "abd", "abc" < "abd")
    print("ell" in String("hello"), "xyz" in String("hello"))
Ubuntu
~$ mojo run operators.mojo
abcdef
abcabcabc
----------
abcdef
True True True
True False

1行目の a + "def" は abcdef、2行目の a * 3 は abcabcabc です。"-" * 10 のようにリテラルに * を使う書き方もできます。b += "cd" のように += で追記できる点も Python と同じです。

比較は、文字の並びを辞書順で比べます。"abc" < "abd" が True なのは、3文字目が c と d で、c のほうが前だからです。

4.3長さを数える:バイトか、文字か

Python で len("こんにちは") と書くと 5 が返ります。Mojo で同じことをするとどうなるでしょうか。

len_error.mojo
def main():
    var s = "こんにちは"
    print(len(s))
Ubuntu
~$ mojo run len_error.mojo
/home/user/len_error.mojo:3:11: error: `len(String/StringSlice)` is not supported because Mojo strings are UTF-8 encoded, so a single length is ambiguous: it could mean the number of UTF-8 bytes, the number of Unicode code points, or the number of user-visible characters (grapheme clusters). Use `s.byte_length()` or `len(s.bytes())` for the number of UTF-8 bytes, `len(s.codepoints())` for Unicode code points, or `len(s.graphemes())` for grapheme clusters.
    print(len(s))
          ^~~
<unknown>:0: note: 'len' declared here
mojo: error: failed to parse the provided Mojo source module

エラーです。メッセージを日本語にすると、次のとおりです。

> len(String/StringSlice) には対応していません。Mojo の文字列は UTF-8 なので、長さがひとつに決まらないからです。UTF-8 のバイト数、Unicode のコードポイント数、見た目の文字数(書記素クラスタ数)のどれのことなのか、はっきりしません。

そして、使うべき関数も教えてくれています。

数えたいもの書き方
UTF-8 のバイト数s.byte_length() または len(s.bytes())
Unicode のコードポイント数len(s.codepoints())
見た目の文字数(書記素クラスタ数)len(s.graphemes())

実際に数えてみましょう。

lengths.mojo
def main():
    var s = "こんにちは"
    print("バイト数:", s.byte_length())
    print("コードポイント数:", len(s.codepoints()))
    print("書記素クラスタ数:", len(s.graphemes()))

    var e = "e\u0301"
    print(e, e.byte_length(), len(e.codepoints()), len(e.graphemes()))
    var emoji = "😀"
    print(emoji.byte_length(), len(emoji.codepoints()))
Ubuntu
~$ mojo run lengths.mojo
バイト数: 15
コードポイント数: 5
書記素クラスタ数: 5
é 3 2 1
4 1

「こんにちは」は、バイト数が 15、コードポイント数が 5、見た目の文字数も 5 です。日本語のひらがなは、1文字が UTF-8 で3バイトになるので、バイト数は文字数の3倍になります。

「こんにちは」の UTF-8 でのバイト列と文字の対応 文字(コードポイント):5 個 len(s.codepoints()) の答え こ U+3053 ん U+3093 に U+306B ち U+3061 は U+306F UTF-8 のバイト:15 個 s.byte_length() の答え E3 0 81 1 93 2 3 バイト E3 3 82 4 93 5 3 バイト E3 6 81 7 AB 8 3 バイト E3 9 81 10 A1 11 3 バイト E3 12 81 13 AF 14 3 バイト 下の数字はバイトの位置。s[byte=3] は「ん」の先頭、s[byte=1] は「こ」の途中なので使えません。
図4-1 「こんにちは」を UTF-8 で保存したときの、文字とバイトの対応

図のように、こ は E3 81 93 という3バイトで表されます。len(s) がひとつの答えを返せないのは、「3バイトずつを1文字とみなす」のが日本語の場合に限った話だからです。英数字は1文字1バイトですし、絵文字 😀 は1つのコードポイントで4バイトになります(最後の行)。

2行目の e\u0301 は、e に「アクセント記号」(U+0301)を重ねた、見た目は é の1文字です。実際には2つのコードポイントでできているので、バイト数は 3、コードポイント数は 2 ですが、人が見て数える文字数(書記素クラスタ数)は 1 になります。

4.4添字とスライス

長さと同じ理由で、s[0] や s[1:3] もそのままでは書けません。

index_error.mojo
def main():
    var s = "こんにちは"
    print(s[0])
Ubuntu
~$ mojo run index_error.mojo
/home/user/index_error.mojo:3:12: error: String does not support direct positional indexing like `s[i]` because Mojo strings are UTF-8 encoded, and the same position can mean three different things. Use one of: `s[byte=i]` for a raw UTF-8 byte, `s[codepoint=i]` for a Unicode code point, or `s[grapheme=i]` for a user-visible character (grapheme cluster).
    print(s[0])
          ~^~~
<unknown>:0: note: '__getitem__' declared here
mojo: error: failed to parse the provided Mojo source module

メッセージによると、位置の意味が3通りあるので、s[byte=i](UTF-8 のバイト位置)、s[codepoint=i](コードポイントの位置)、s[grapheme=i](見た目の文字の位置)のどれかを選ぶ必要があります。スライスも同じ形で、s[codepoint=1:3] のように書きます。

indexing.mojo
def main():
    var s = "こんにちは"
    print(s[codepoint=0])
    print(s[codepoint=1])
    print(s[grapheme=4])
    print(s[codepoint=1:3])
    print(s[byte=3])
    print(s[byte=3:9])
Ubuntu
~$ mojo run indexing.mojo
こ
ん
は
んに
ん
んに

s[codepoint=1] は2文字目の ん、s[codepoint=1:3] は2〜3文字目の んに です。Python の s[1]、s[1:3] と同じ結果になります。

バイト位置のほうは、図のとおり1文字が3バイトなので、s[byte=3] が ん、s[byte=3:9] が んに になります。s[byte=3] は「3バイト目の1バイト」ではなく、3バイト目から始まる1文字 を返す点に注意してください。

文字の途中を指すとプログラムが止まる

バイト位置のほうは、文字の途中を指すことができてしまいます。s[byte=1] は、こ の2バイト目を指します。

mid_char.mojo
def main():
    var s = "こんにちは"
    print(s[byte=1])
Ubuntu
~$ mojo run mid_char.mojo 2>&1 | head -2
At: /home/user/mid_char.mojo:3:12: Assert Error: String span index, 1 does not lie on a codepoint boundary.
#0 0x00007437d77c4d1e (/home/user/mojo-book-env/.pixi/envs/default/lib/libKGENCompilerRTShared.so+0x112d1e)

ABORT や Assert Error は、プログラムが異常終了した ことを表します。文字の境界ではない位置を指したことを、Mojo が検出して止めたためです。try / except で受け止めることはできません。範囲外の位置でも同じです。

out_of_range.mojo
def main():
    var s = "こんにちは"
    print(s[codepoint=10])
Ubuntu
~$ mojo run out_of_range.mojo 2>&1 | head -2
ABORT: Mojo/stdlib/std/collections/string/string_span.mojo:438:18: codepoint index is out of bounds: 10
#0 0x000074dd63dc4d1e (/home/user/mojo-book-env/.pixi/envs/default/lib/libKGENCompilerRTShared.so+0x112d1e)
last_char.mojo
def main():
    var s = "こんにちは"
    var n = len(s.codepoints())
    print(s[codepoint=n - 1])
    print(s[codepoint=n - 2:n])
Ubuntu
~$ mojo run last_char.mojo
は
ちは

4.51文字ずつ取り出す

文字列の中身を順に処理したいときは、for で回します。文字列をそのまま回すと、1文字ずつの文字列が取り出せます。

iterate.mojo
def main():
    var s = "こんにちは"
    for c in s:
        print(c, end=" ")
    print()

    for c in s.codepoints():
        print(c.to_u32(), end=" ")
    print()

    print(ord("あ"), chr(12354))
Ubuntu
~$ mojo run iterate.mojo
こ ん に ち は 
12371 12435 12395 12385 12399 
12354 あ

1つ目のループは こ ん に ち は と、文字がそのまま出ます。

2つ目の s.codepoints() は、文字を コードポイント(文字に割り当てられた番号)として取り出します。c.to_u32() で、その番号を整数に直しています。12371 は こ の番号(U+3053)です。

3行目の ord と chr は Python と同じで、ord("あ") は文字から番号を、chr(12354) は番号から文字を返します。

s.codepoint_slices() を使うと、s.codepoints() と違って、番号ではなく1文字分の文字列として取り出せます。ただ、for c in s と結果が同じなので、本書では for c in s を使います。

4.6よく使うメソッド

Python の文字列メソッドと同じ名前のものが、たくさんあります。

methods.mojo
def main():
    var s = String("Hello, World")
    print(s.upper(), s.lower())
    print("[" + String("  padded  ").strip() + "]")
    print(s.find("World"), s.find("xyz"), s.rfind("o"))
    print(s.count("o"))
    print(s.replace("World", "Mojo"))
    print(s.startswith("Hello"), s.endswith("World"))

    var parts = String("a,b,c").split(",")
    print(len(parts), parts[0])
    print(parts)
    print("-".join(parts))

    var words = String("  one   two three ").split()
    for w in words:
        print("<" + w + ">")
    print(String("l1\nl2\nl3").splitlines())
Ubuntu
~$ mojo run methods.mojo
HELLO, WORLD hello, world
[padded]
7 -1 8
2
Hello, Mojo
True True
3 a
[a, b, c]
a-b-c
<one>
<two>
<three>
[l1, l2, l3]

行ごとに見ていきます。

リストの扱いは第5章で詳しく学びます。ここでは「split の結果は parts[0] のように取り出せる」と理解しておけば十分です。

4.7数値と文字列の変換

数値を文字列にするには String(...)、文字列を整数にするには Int(...)、小数にするには Float64(...) を使います。

convert.mojo
def main() raises:
    var n = 42
    var text = String(n)
    print(text + "!", text.byte_length())

    print(String(3.14), String(True))

    var a = Int("42")
    var b = Float64("2.5")
    print(a + 1, b + 1)

    print(Int(" 42 "))
Ubuntu
~$ mojo run convert.mojo
42! 2
3.14 True
43 3.5
42

String(n) は、整数 42 を "42" にします。"n = " + n のように文字列と数値を直接 + でつなぐことはできず、String(n) で変換が必要です(次の節で実際のエラーを見ます)。

Int("42") は文字列を整数にします。前後の空白(" 42 ")は許されます。

def main() raises: と書いてあるのに注意してください。Int("...") は変換に失敗するとエラーを送り出す(raises)関数だからです。raises は第9章で詳しく学びます。

変換に失敗すると

数字ではない文字列を Int に渡すと、どうなるでしょうか。

parse_fail.mojo
def main() raises:
    var x = Int("abc")
    print(x)
Ubuntu
~$ mojo run parse_fail.mojo
stack trace was not collected. Enable stack trace collection with environment variable `MODULAR_DEBUG=stack-trace-on-error`
Unhandled exception caught during execution: String is not convertible to integer with base 10: 'abc'
mojo: error: execution exited with a non-zero result: 1

Unhandled exception caught during execution: に続いて、String is not convertible to integer with base 10: 'abc'(文字列は10進数の整数に変換できません)と出て、プログラムが終了しました。Python の ValueError に当たります。

プログラムを止めたくないときは、try / except で受け止めます。

parse_try.mojo
def parse(text: String) -> Int:
    try:
        return Int(text)
    except e:
        print("変換できません:", e)
        return -1

def main():
    print(parse("123"))
    print(parse("12.5"))
    print(parse("abc"))
Ubuntu
~$ mojo run parse_try.mojo
123
変換できません: String is not convertible to integer with base 10: '12.5'
-1
変換できません: String is not convertible to integer with base 10: 'abc'
-1

"12.5" も Int には変換できないことに注意してください。小数点を含む文字列は、Float64("12.5") で小数として読みます。

4.8文字列を組み立てる

+ でつなぐ以外に、文字列を組み立てる方法が3つあります。

String(...) に複数の値を渡す

String(...) は、複数の値を受け取って、1つの文字列にします。数値を String(n) で変換する手間もいりません。sep で区切りも指定できます。

build_string.mojo
def main():
    var n = 7
    print(String("n = ", n))
    print(String("a", 1, "b", sep="-"))
    print(String("x", "y", "z"))
Ubuntu
~$ mojo run build_string.mojo
n = 7
a-1-b
xyz

引数の間には、何も指定しなければ空白は入らず、そのままつながります(1行目と3行目)。最初の例で n = の後ろに空白を書いているのはそのためです。sep を指定すると、その文字列が間に入ります。

テンプレート文字列 t"..."

Python の f 文字列に当たるのは、引用符の前に t を付けた テンプレート文字列 です。{} の中に変数や式を書くと、その値が文字列として入ります。使うときは、String(...) で包んで文字列にします。

tstring.mojo
def main():
    var name = "Mojo"
    var n = 3
    var t = t"hi {name}, n = {n}"
    print(String(t))
    print(String(t"{n} + 1 = {n + 1}"))
Ubuntu
~$ mojo run tstring.mojo
hi Mojo, n = 3
3 + 1 = 4

Python の f 文字列(f"hi {name}")は、Mojo 1.2.0 では書けません。

fstring.mojo
def main():
    var name = "Mojo"
    print(f"hi {name}")
Ubuntu
~$ mojo run fstring.mojo
/home/user/fstring.mojo:3:12: error: expected ')' in call argument list
    print(f"hi {name}")
           ^
mojo: error: failed to parse the provided Mojo source module

format

"{}" を使う format もあります。{} の部分が、引数で順に置き換わります。

format.mojo
def main():
    print("{} + {} = {}".format(1, 2, 3))
    print("name: {}".format("Mojo"))
Ubuntu
~$ mojo run format.mojo
1 + 2 = 3
name: Mojo

Python の "{:>5}".format(2) のように、{} の中に書式指定(右寄せなど)を書くと、Mojo 1.2.0 ではコンパイルエラーになります({:>5} を試すと Index :>5 not in kwargs と言われました)。書式を整えたいときは、String(...) や t"..." で組み立てたあと、自分で空白を足します。

plus_int.mojo
def main():
    var n = 1
    var x = "n=" + n
Ubuntu
~$ mojo run plus_int.mojo 2>&1 | head -3
/home/user/plus_int.mojo:3:20: error: no matching method in call to '__add__'
    var x = "n=" + n
            ~~~~~~~^

エラーの先頭に no matching method in call to '__add__'(+ に当たる処理が見つからない)と出ます。+ が受け付けるのは、文字列どうしだけです。

4.9複数行の文字列とエスケープ

三重引用符 """...""" で、複数行にまたがる文字列を書けます。

multiline.mojo
def main():
    var poem = """first line
second line
third line"""
    print(poem)
    print(len(poem.splitlines()))
Ubuntu
~$ mojo run multiline.mojo
first line
second line
third line
3

エスケープシーケンスは、バックスラッシュ(\)で始めます。

書き方意味
\n改行
\tタブ
\\バックスラッシュ
\"二重引用符
\x4116進数2桁で指定した文字(この例では A)
\u304216進数4桁で指定した Unicode の文字(この例では あ)
escapes.mojo
def main():
    print("line1\nline2")
    print("tab:\there")
    print("back\\slash and \"quote\"")
    print("\x41 \u3042")
Ubuntu
~$ mojo run escapes.mojo
line1
line2
tab:	here
back\slash and "quote"
A あ

\u のあとは、必ず4桁 の16進数です。それより大きい番号(絵文字など)は、文字そのものを書いてください。\u{1F600} のような書き方は、エラーになります。

raw.mojo
def main():
    print(r"C:\new\table")
Ubuntu
~$ mojo run raw.mojo
C:\new\table

引用符の前に r を付けた raw 文字列 では、バックスラッシュがそのまま文字として扱われます。Windows のパスなどを書くときに便利です。

4.10Python との違いのまとめ

やりたいことPythonMojo 1.2.0
文字数len(s)len(s.codepoints())
バイト数len(s.encode())s.byte_length()
見た目の文字数(標準にはない)len(s.graphemes())
1文字取り出すs[1]s[codepoint=1]
スライスs[1:3]s[codepoint=1:3]
最後の文字s[-1]位置を計算する(負の位置は使えない)
1文字ずつ回すfor c in s:for c in s:
数値を文字列にstr(42)String(42)
文字列を整数にint("42")Int("42")
文字列を組み立てるf"n={n}"t"n={n}"(String(...) で包む)、String("n=", n)
変換の失敗ValueErrortry / except で受け止める

4.11まとめ

確認クイズ

  1. Mojo で len("こんにちは") と書くと、どうなりますか。代わりに何を書きますか。

    解答を見る

    コンパイルエラーになります。UTF-8 の文字列では長さがひとつに決まらないためです。バイト数は s.byte_length()(15)、コードポイント数は len(s.codepoints())(5)、見た目の文字数は len(s.graphemes())(5)で数えます。

  2. "こんにちは" のバイト数が15になるのはなぜですか。

    解答を見る

    UTF-8 では、ひらがな1文字が3バイトで表されるからです。5文字 × 3バイトで15バイトになります。

  3. 文字列 s の2文字目を取り出すには、どう書きますか。

    解答を見る

    s[codepoint=1] です。バイト位置で指す s[byte=i] は、i が文字の境界でないとプログラムが止まるので、日本語を含む文字列では codepoint を使うほうが安全です。

  4. Int("abc") はどうなりますか。止めずに扱うにはどうしますか。

    解答を見る

    変換に失敗してエラーになり、そのままではプログラムが終了します。try: ... except e: ... で受け止めれば、エラーメッセージ e を表示するなどして続けられます。

  5. Python の f 文字列に当たる書き方は何ですか。

    解答を見る

    t"..." のテンプレート文字列です。String(t"n = {n}") のように String(...) で包んで文字列にします。f"..." はそのままでは書けません。

練習問題 文字数と先頭の文字

"こんにちは世界" について、バイト数、コードポイント数、先頭の文字、最後の文字を表示するプログラムを書いてください。

解答例を見る
ex1.mojo
def main():
    var s = "こんにちは世界"
    var n = len(s.codepoints())
    print(s.byte_length(), n)
    print(s[codepoint=0], s[codepoint=n - 1])
Ubuntu
~$ mojo run ex1.mojo
21 7
こ 界

バイト数は 7 × 3 = 21、コードポイント数は 7 です。最後の文字は、位置を n - 1 と計算して取り出します。

練習問題 数字を足す

文字列 "10,20,30" を , で分けて、各要素を Int に変えて合計を表示してください。

解答例を見る
ex2.mojo
def main() raises:
    var parts = String("10,20,30").split(",")
    var total = 0
    for p in parts:
        total += Int(p)
    print(total)
Ubuntu
~$ mojo run ex2.mojo
60

split の結果の各要素を Int(p) で整数にして、足しています。数字でない要素があれば Int が失敗するので、main に raises が付いています。

練習問題 文字を逆順にする

"mojo" の文字を逆順にして ojom と表示するプログラムを書いてください。(ヒント:for で1文字ずつ取り出し、新しい文字列の先頭にくっつけていく。)

解答例を見る
ex3.mojo
def main():
    var s = "mojo"
    var rev = String()
    for c in s:
        rev = String(c) + rev
    print(rev)
Ubuntu
~$ mojo run ex3.mojo
ojom

取り出した1文字を、これまでに作った文字列の 前 に付けることで、逆順になります。日本語の文章でも、for c in s はコードポイント単位で回るので同じように動きます。