ハッシュマップと文字列――Rustのコレクションをさらに理解する基本からしっかり学ぶRust入門(10)

Rustについて基本からしっかり学んでいく本連載。第10回は、Rustのハッシュマップと文字列について。

» 2022年04月22日 05時00分 公開

この記事は会員限定です。会員登録(無料)すると全てご覧いただけます。

「基礎からしっかり学ぶRust入門」のインデックス

連載:基礎からしっかり学ぶRust入門

 本連載のサンプルコードをGitHubで公開しています。こちらからダウンロードしてみてください。具体的な利用方法は連載第1回を参考にしてください。


 第9回ではデータ型を抽象化するジェネリクスと、ジェネリクスを生かしたデータ型でコレクションの1つでもあるベクターを紹介しました。今回は、残るコレクションであるハッシュマップと文字列を紹介します。

ハッシュマップ

  ハッシュマップの具体的な利用方法を見ていきます。

ハッシュマップ変数を宣言する

 ハッシュマップも、ベクターと同様にnew()メソッドを用いて宣言します。

use std::collections::HashMap;                  (1)
…略…
let mut months: HashMap<String, i32> = HashMap::new();  (2)
let mut months12: HashMap<String, i32> = HashMap::with_capacity(12);    (3)
src/bin/hashmap_decraration.rs

 (1)では、ハッシュマップの使用をuse文により宣言しています。このように、ハッシュマップのためのモジュールを有効にするuse文が必要なことに注意してください。これは、ベクターに比べてハッシュマップの使用頻度はそれほど高くないという判断に基づくようです。

 (2)では、変数monthsをHashMap<String, i32>型のインスタンスを生成して所有させています。ここではキーにString型を指定していますが、ほかにboolean型、i32型などの整数型など、EqトレイトとHashトレイトを実装したデータ型をキーに指定することができます(トレイトについては次回以降で紹介します)。このように、ハッシュマップはHashMap型の構造体を生成して使うことになります。ここで、型注釈を使用して型を明示しているのに注意してください。HashMap型のnew()メソッドを呼び出しただけではハッシュマップの中身は空なので、キーと値の型を特定できないからです。型注釈を省略するには、次で紹介するinsert()メソッドなどでキーと値のデータ型を明確にするか、ベクターを使った生成を用います。

 (3)では、with_capacity()メソッドを使用してハッシュマップを生成しています。with_capacity()メソッドは引数に整数値を受け取り、その大きさ分のメモリ領域をあらかじめ確保します。ハッシュマップへ値を追加していくなどしても、この数を超えない限りメモリ領域は再配置されません。あらかじめ、上限が分かっている(例のように12個が最大であるなど)場合には速度面で有利ですが、使われない部分は無駄になるので、バランスを考えて使用しましょう。なお0を指定した場合、ハッシュマップの生成時点ではメモリ領域は確保されず、最初の追加ではじめてメモリ領域が確保されます。

 ハッシュマップには、ベクターにおけるvec!のようなマクロは用意されていません。その代わりというわけではないですが、ベクターからハッシュマップを生成することができます。これには、キーだけのベクター、値だけのベクターを作り、それをcollect()メソッドでまとめます。

let keys = vec!["January", "February", "March", "April"];
let values = vec![1, 2, 3, 4];
let months: HashMap<_, _> = keys.iter().zip(values.iter()).collect();   (1)
println!("{:?}", months);
  // {"April": 4, "March": 3, "February": 2, "January": 1}
src/bin/hashmap_collect.rs

 (1)に注目してください。keys.iter()は、keysに対するイテレータを返しますが、そのzip()メソッドがvalues.iter()と合わせて1つのイテレータにまとめてくれます。これにcollect()メソッドを適用することで、ハッシュマップに変換します。なお、monthsの初期化にはHashMap<_, _>型を明示していますが、ここに含まれるアンダースコア(_)により、ベクターの型から自動的にキーと値の型を決めてくれます。

【補足】文字列をハッシュに入れるとき

 ハッシュにおいても、文字列をキーや値に使用する場合にはベクターと同様の注意が必要です。第9回の補足「文字列をベクターに入れるとき」を参照してください。

ハッシュマップに値を追加する

 ハッシュマップに値を追加するには、insert()メソッドを使用します。以下の例は、初期化直後で空のハッシュマップに値を追加していきます。

use std::collections::HashMap;          (1)
…略…
let mut months = HashMap::new();
months.insert("January", 1);        (2)
months.insert("February", 2);
months.insert("March", 3);
months.insert("April", 4);
println!("{:?}", months);
  // {"March": 3, "January": 1, "April": 4, "February": 2}
src/bin/hashmap_insert.rs

 (2)では、insert()メソッドによりキーと値の組み合わせをハッシュマップに追加しています。このとき、キーは文字列への参照、値は整数型と型推論が働きます。このため、変数の初期化時には型の指定が不要となっています。また、表示結果を見ると、順番が挿入順になっていないことに気付きます。このように、ベクターでは順序があったものがハッシュマップでは追加の順序は意味を持ちません。なお、println!()のフォーマット引数{:?}は、第9回でも紹介したDisplayトレイトを用いて(この場合はハッシュマップの)値を一覧表示するという指示です。

 ベクターと同様にキーのデータ型と値のデータ型は、それぞれ同一である必要があります。この場合はキーは文字列リテラル(&str)となるので、String型(とその参照)との混在はできません。

ハッシュマップの値を参照する

 ハッシュマップに格納されている値を参照するには、get()メソッドを使用します。ベクターのような、添え字を使用する記法はありません。

println!("Aprilは {}月です。", months.get("April").unwrap());
  // Aprilは 4月です。
src/bin/hashmap_ref.rs

 get()メソッドの引数にキー(この場合は文字列リテラル)を与えて呼び出すと、Option<T>型の値が戻り値として返ってきます。有効な値がないときはNoneが返り、有効な値があるときにはSome(&V)が返ってくるのは、ベクターと同じです(&Vはキーに対応する値への参照)。unwrap()メソッドを用いて値を取り出すのは、Option<T>型の定石です。

 なお、キーが文字列リテラルの場合、get()メソッドの引数も文字列リテラルである必要があります。String型の参照は使用できませんので注意が必要です。

ハッシュマップの値を変更する

 ハッシュマップには、ベクターにはない値の更新という処理があります。値の更新方法には、「値の上書き」と「キーが新規の場合に値を追加する」の2種類があります。

 値の上書きは、同一のキーで値を何度追加しても、最後に追加された値のみが有効になるというものです。意図的に値を上書きしているなら理にかなった動きですが、これが意図したものではないということを知りたいという場合はどうでしょうか? このようなときには後者の方法を使います。

 キーが新規の場合にのみ値を追加するには、entry()メソッドを使用します。entry()メソッドは、引数にキーを指定して呼び出すことでEntry型というキーの存在を示す列挙型の値を返します。Entry型にはor_insert()メソッドがあり、この値についてこのメソッドを呼び出すと、キーが存在しない場合には追加された値への変更可能な参照が、キーが存在すればその値への変更可能な参照が、それぞれ返されます。

months.insert("April", 3);              (1)
months.insert("April", 4);
println!("Aprilは {}月です。", months.get("April").unwrap());
  // Aprilは 4月です。
months.entry("May").or_insert(5);       (2)
println!("Mayは {}月です。", months.get("May").unwrap());
  // Mayは 5月です。
src/bin/hashmap_update.rs

 (1)からの2行は、同じキーである"April"を使って値を追加していますが、有効なのは2番目のものだということが分かります。(2)では、存在しないキーである"May"を指定してentry()メソッドを呼び出し、キーが存在しない場合に値5を追加しています。

ハッシュマップから値を取り出し削除する

 ハッシュマップからキーと値を削除するには、remove()メソッドを使用します。remove()メソッドは、引数にキーを受け取って、Option<T>型の値を返します。キーが存在すればSome(&V)を返し、存在しなければNoneを返します。

let mut months = HashMap::new();
months.insert("January", 1);
months.insert("February", 2);
months.insert("March", 3);
months.insert("April", 4);
let march = months.remove("March");             (1)
if march != None {                              (2)
    println!("Marchは削除されて {}月でした。", march.unwrap());
      // Marchは削除されて 3月でした。
}
src/bin/hashmap_remove.rs

 (1)では、remove()メソッドにてキー"March"を削除し、その結果を変数marchで受け取っています。(2)では、marchがNoneでなければキーが存在したものと見なして、その値を表示しています。

【補足】HashSet

 ハッシュマップと同様にハッシュ関数を用いてキーを格納できるハッシュセット(HashSet)というコレクションもあります。HashSetは、ハッシュマップから値を取り除いた(正確には空のタプルのみを値とする)コレクションです。キーと値のマッピングがないので、キーというよりは値をランダムで保持し、ハッシュ関数によって高速に検索できるリストのようなものと思えば良いでしょう。

文字列

 第9回では、文字列もコレクションであると書きました。文字列(String型)はベクターのラッパーです。Vec<u8>型のフィールドを持ち、独自のメソッドが追加、修正されたものとなっています。ここでは、文字列ならではの操作を中心に紹介していきます。

文字列型変数を宣言する

 文字列型変数の宣言については、これまでも散々取り上げました。ここまでコレクション型変数の初期化を見てくると、全く同じ形で文字列型変数を宣言してきたことに気付くでしょう。以下は、文字列型変数を宣言する例です。

let month = String::new();
src/bin/string_decraration.rs

 String型の関連関数であるnew()メソッドを呼び出し、String型のインスタンスを生成して所有させています(文字列型にはジェネリクスはありません)。これが最も基本的な形ですが、文字列リテラルから初期値として文字列を与えて変数を宣言することもできます。これも、これまで散々取り上げてきた形です。以下は、初期値を与えて文字列型変数を宣言する例です。

let jan = String::from("January");
src/bin/string_decraration.rs

 同じく関連関数であるfrom()メソッドを用いて、文字列リテラル(&str)の内容を初期値としてString型のインスタンスを生成して所有させています。

 第9回で、文字列型の中身はUTF-8エンコーディングされたバイトデータの列であるということに触れました。ですので、UTF-8エンコーディングされたバイトデータを直接使って文字列型変数を初期化することもできます。これにはfrom_utf8()メソッドを使います。

let jan = String::from("January");
let feb_utf8 = vec![0x46, 0x65, 0x62, 0x72, 0x75, 0x61, 0x72, 0x79];    (1)
let feb = String::from_utf8(feb_utf8).unwrap();                         (2)
println!("{},{}", jan, feb);    // January,February
src/bin/string_decraration.rs

 最初に(1)で、vec!マクロを使って「February」に相当するUTF-8のバイト列を生成しています。これを(2)でfrom_utf8()メソッドに渡して文字列を生成しています。ここで、unwrap()メソッドを使っていることに注意してください。from_utf8()メソッドは第8回で紹介したResult型の値を返すため、unwrap()メソッドで本来の値(この場合はString)を取り出す必要があるのです。このことから分かるように、from_utf8()メソッドは正しいUTF-8のバイト列が与えられなかった場合にはエラー(FromUtf8Error型)を返します。

文字と文字列を追加する

 文字列型変数に何かを追加する方法は2つあります。文字単独の追加と文字列の追加です。文字(char型)を単独で追加するには、ベクター型と同様にpush()メソッドを使用します。文字列を追加するには、push_str()メソッドを使用します。push_str()メソッドでは、引数は&strすなわち文字列リテラルか、String型への参照(&String)である必要があります。以下は、文字と文字列を次々と追加して1個の文字列とする例です。

let mut months = String::new();
months.push('J');
months.push('a');
months.push('n');
months.push('u');
months.push('a');
months.push('r');
months.push('y');
months.push_str(",February");
months.push_str(&String::from(",March"));
months.push_str(&String::from(",April"));
println!("{}", months);	// January,February,March,April
src/bin/string_push.rs

 文字列リテラルを指定しても、文字列型への参照を指定しても結果は同じですが、文字列型への参照を渡す場合にはいったん文字列型の一時的な値が生成されることに注意してください。また、ここではpush()メソッドの引数にはASCII文字を指定しましたが、'あ'などのマルチバイト文字を指定しても問題ありません。Rustの文字(char型)はUnicodeであり、文字列型変数に追加される際に適切にUTF-8エンコーディングされます。

UTF-8エンコーディング

部分文字列を参照する

 ある文字列を扱うとき、その部分文字列が必要というのはよくあります。このようなときにはスライスが有用であることに、第6回で触れました。しかし、このスライスは常に安全というわけではありません。例えば、以下では実行時にpanicになります。

let english = String::from("Hello, world!");
let japanese = String::from("こんにちは、世界!");
let world = &english[7..12];
let sekai = &japanese[6..8];
println!("{} は {}", world, sekai);
src/bin/string_slice.rs
thread 'main' panicked at 'byte index 8 is not a char boundary; it is inside 'に' (bytes 6..9) of `こんにちは、世界!`', src/bin/string_slice.rs:5:18

 「世界」は0から数えて6文字目なので、[6..8]とスライスを指定しましたが、8の方が文字の境界になかったという指摘です。というのは、UTF-8では1文字が何バイトで構成されているかは字種によるので、見た目の何文字目というのには意味がありません。「world」の方が問題ないのは、これを構成する文字が全て1バイトだからで、見た目の文字数と一致するからです。スライスを正しく指定するには、文字列を構成する文字が何バイトであるかを把握する必要があり、文字列の値が実行時にならないと決まらないような場合には、スライスを決め打ちするのは不可能です。ちなみに上記では、正しいスライスは[18..24]となります。平仮名は3バイトで構成されているのです。

 文字列の何文字目が何バイト目であるかを調べるには、後述するfind()メソッドを用いるか、文字列の先頭から走査するなどします。このために、String型にはイテレータを返すbytes()、char()、char_indices()メソッドが用意されています。以下は、char()メソッドとchar_indices()メソッドを使って文字列の内容を1文字ずつ表示します。

let english = String::from("Hello, world!");
let japanese = String::from("こんにちは、世界!");
for c in english.chars() {
    print!("{} ", c);   // H e l l o ,   w o r l d !
}
println!();
for c in japanese.char_indices() {
    print!("{}:{} ", c.0, c.1);
      // 0:こ 3:ん 6:に 9:ち 12:は 15:、 18:世 21:界 24:!
}
println!();
src/bin/string_iteration.rs

 char_indices()メソッドが返すイテレータでは、それぞれの値は位置と文字からなるタプルとなります。また、chars()メソッドの代わりにbytes()メソッドを使うと、文字ではなくバイトデータ単位で繰り返すことができます。

【補足】インデックスでのアクセス

 文字列型は配列やベクターのようなインデックスによるアクセスはできません。例えば以下のように書くと、コンパイルエラー(cannot be indexed by `{integer}`)となります。

println!("{}", english[0]);

文字列を挿入・削除する

 文字列の末尾への追加ではなく、insert()メソッドおよびinsert_str()メソッドを使って挿入もできます。それぞれ、文字の挿入、文字列の挿入になります。注意することは、挿入位置はバイト位置だということです。このため、文字の境界位置を指定してやらないとpanicになります。以下は、insert()メソッドとinsert_str()メソッドを使って文字、文字列を挿入する例です。

let mut english = String::from("Helo, world!");
let mut japanese = String::from("こんにちは、世界!");
english.insert(2, 'l');
println!("{}", english);        // Hello, world!
japanese.insert_str(18, "もうひとつの");
println!("{}", japanese);       // こんにちは、もうひとつの世界!
src/bin/string_insert.rs

 insert()メソッドおよびinsert_str()メソッドは、元の文字列を変更することに注意してください。ですので、参照など借用されている場合には変更できません。このような場合には、clone()メソッドを使って複製を作成する必要があります。

 文字列から文字を削除するには、remove()メソッドを使います。remove()メソッドは、削除する文字をバイト位置で受け取り、削除された文字を返します。バイト位置が文字列より大きいか、文字の境界になっていない場合にはpanicになります。以下は、remove()メソッドで文字を削除する例です。

let mut english = String::from("Hello, world!");
let mut japanese = String::from("こんにちは、もうひとつの世界!");
let mut c = english.remove(2);
println!("{}", c);      // l
c = japanese.remove(18);
println!("{}", c);      // も
src/bin/string_replace.rs

【補足】replace_matches()メソッド

 文字列を指定して一致する部分を削除するreplace_matches()メソッドもありますが、Nightly-onlyな実験的実装です。使用にはnightly版のビルドツール(例えばCargo)とfeature注釈が必要です。feature注釈はいわゆるアノテーションのひとつで、feature gateという仕組みを使ってAPIを有効化するために記述します。

#![feature(string_remove_matches)]
…中略…
english.remove_matches("o");

文字列から検索、置換する

 文字列から検索するには、find()メソッドを使います。find()メソッドには、検索する文字あるいは文字列を引数に与えます。find()メソッドの戻り値はOption<usize>型で、見つかった場合にはバイト位置を含むSome(&usize)を、見つからなかった場合にはNoneを返します。以下は、find()メソッドを使って文字や文字列を検索する例です。

let english = String::from("Hello, world!");
let japanese = String::from("こんにちは、世界!");
let mut res = english.find('w');
if res == None {
    println!("見つかりません。");
} else {
    println!("{} バイト目で見つかりました!", res.unwrap());
    // 7 バイト目で見つかりました!
}
res = japanese.find("世界");
if res == None {
    println!("見つかりません。");
} else {
    println!("{} バイト目で見つかりました!", res.unwrap());
    // 18 バイト目で見つかりました!
}
src/bin/string_find.rs

 find()メソッドは、戻り値がOption<&usize>型であるので、unwrap()メソッドで値を取り出していることに注意してください(もう慣れましたね)。

 置換するには、replace()メソッドを用います。replace()メソッドにも、置換する文字あるいは文字列と、置換後の文字列を引数に与えます。replace()メソッドはinsert()メソッドなどと異なり、置換後の文字列を新たに生成して返します。つまり、元の文字列には影響しません。一致する部分がなかった場合には、元の文字列と同じ文字列が返ります。以下は、replace()メソッドを使って文字や文字列を置換しています。

let english = String::from("Hello, world!");
let japanese = String::from("こんにちは、世界!");
let mut res = english.replace('o', "a");
println!("{}", res);    // Hella, warld!
res = japanese.replace("世界", "World");
println!("{}", res);    // こんにちは、World!
src/bin/string_replace.rs

 1番目のreplace()メソッドの実行結果から分かりますが、replace()メソッドは一致するものは全て置換します。回数を指定したい場合には、replacen()メソッドを使ってください。

まとめ

 今回は、コレクションの続きとして、ハッシュマップと文字列を紹介しました。文字列についてはまだまだ語り尽くせない部分があるのですが、片りんだけでも知っていただけたのではないでしょうか?

 次回は、さらに関連した内容として、ジェネリクス型の実装とトレイトについて取り上げます。

筆者紹介

WINGSプロジェクト

有限会社 WINGSプロジェクトが運営する、テクニカル執筆コミュニティー(代表山田祥寛)。主にWeb開発分野の書籍/記事執筆、翻訳、講演等を幅広く手掛ける。2021年10月時点での登録メンバーは55人で、現在も執筆メンバーを募集中。興味のある方は、どしどし応募頂きたい。著書、記事多数。

サーバーサイド技術の学び舎 - WINGS(https://wings.msn.to/
RSS(https://wings.msn.to/contents/rss.php
Twitter: @yyamada(https://twitter.com/yyamada
Facebook(https://www.facebook.com/WINGSProject


Copyright © ITmedia, Inc. All Rights Reserved.

スポンサーからのお知らせPR

注目のテーマ

Microsoft & Windows最前線2025
AI for エンジニアリング
ローコード/ノーコード セントラル by @IT - ITエンジニアがビジネスの中心で活躍する組織へ
Cloud Native Central by @IT - スケーラブルな能力を組織に
システム開発ノウハウ 【発注ナビ】PR
あなたにおすすめの記事PR

RSSについて

アイティメディアIDについて

メールマガジン登録

@ITのメールマガジンは、 もちろん、すべて無料です。ぜひメールマガジンをご購読ください。