Rust のトレイトを使おう!(1)
みなさんこんにちは.突然ですが,Rust のトレイトは使っていますか?
トレイトは Rust のもっとも基本的な(簡単な,という意味ではない)抽象化一つであり,一度慣れてしまえばとても便利に使えます.
一方で抽象化とは得てして理解しがたいものですから,実際のプログラミングでは避けられがちかもしれません.
Rust のトレイトは色んな側面があり,一概に「トレイトはこういうものだ」と言うことはできません.しかし敢えて一言でまとめるなら,
具体的な実装に依存しない処理を表す
のがトレイトとなります.
ここでは,「具体的な実装に依存しない処理を表す」とはどういう意味なのか,具体例を通じて見ていこうと思います.
大きく Generics/impl Trait
/Miscellaneous という3つに分けていますが,この分類は説明の便宜上なものなので,特に気にしなくても大丈夫です.
なお以下で解説するトレイトの性格はあくまで一例であり,トレイトのすべてを網羅的に述べるものではありません.
※長くなったので前後半に分けました.後半はこちら.(後半の方が重要度は高いかもしれません…….)
Generics としてのトレイト
Generics とは,大雑把に言えば MyStruct<T>
や std::str::parse::<T>()
の <T>
のことです(参考:https://turbo.fish/).
ケース1:変換方法を表す
まず次のコードを見てください:
use std::path::Path;
use std::{io, fs};
fn open_file(path: &Path) -> io::Result<fs::File> {
fs::File::open(path)
}
ファイルのパスを受け取ってそのファイルを返す,という単純な関数です.
単純な関数ですが,ユーザからすればやや面倒です.なぜなら,ユーザはきっと "/path/to/file"
や String::from("/path/to/file")
のように &str
や String
としてファイルパスを持っているはずで,ユーザが自分で Path
へ変換しなければならないからです.
let path = "/path/to/file";
let file = open_file(Path::new(path))?;
大した労力ではありませんが,面倒なのはたしかです.変換方法を調べる必要もあるかもしれません.
Generics を使えば次のように簡単になります:
use std::path::Path;
use std::{io, fs};
fn open_file<P: AsRef<Path>>(path: P) -> io::Result<fs::File> {
fs::File::open(path)
}
let path = "/path/to/file";
let file = open_file(path)?;
このように,
変換をユーザが自前で行うのではなく,関数の中でやってくれる
ということをしたいならトレイトが使えます.
ケース2:DRY (Don't Repeat Yourself)
上のケース1とも被りますが,DRY つまり繰り返しを避けるのも generics の重要な役目です.
「画像を生成してバッファに保存する」という関数を考えましょう.
fn generate_image(buf: &mut [u8]) {
// buf に画像データを書き込み
}
buf: &mut [u8]
だけでなくファイルにも書き込みたくなったとしましょう:
fn generate_image_to_file(file: &std::fs::File) {
// file に画像データを書き込み
}
あるいは,HTTP リクエストの body にも書きたくなります:
struct Socket {}
struct Request {
socket: Socket,
}
fn generate_image_to_http_req(req: &mut Request) {
// req.socket に body として画像データを書き込み
}
(もしかしたら Request
は Write
ではなく AsyncWrite
かもしれませんが,ここでは単なる Write
だとしておきます.)
同じような関数を複数も容易するのは無駄ですし,メンテナンス性も落ちてしまいます.欲しい書き込み先が増えたときに関数を増やすのも面倒です.
Generics を使えば簡単に書けます:
use std::io::Write;
fn generate_image<W: Write>(writer: W) {
// writer に画像データを書き込み
}
ケース3:複雑な・不明な引数の型名を省略する
たとえばイテレータの型名は長くなりがちです.また,クロージャの型名は一般には不明です.
// WhatType???
fn do_something(it: WhatType) {}
let v: Vec<u64> = Vec::new();
let it = v.iter().filter(|&n| n % 10 == 0).rev().map(ToString::to_string);
do_something(it);
このような場合には,そもそも具体的な型名が使えないように設計されているので,トレイトを使わざるを得ません:
fn do_something<T: Iterator<Item = String>>(it: T) {}
let v: Vec<u64> = Vec::new();
let it = v.iter().filter(|&n| n % 10 == 0).rev().map(ToString::to_string);
do_something(it);
impl Trait
impl Trait
は引数の位置と返り値の位置とで意味が異なります.
引数としての impl Trait
fn print(arg: impl ToString) {
println!("{}", arg.to_string());
}
は generics を使った関数
fn print<T: ToString>(arg: T) {
println!("{}", arg.to_string());
}
と(ほぼ)等価です.
一方で返り値における impl Trait
fn double(v: Vec<u64>) -> impl Iterator<Item = usize> {
v.into_iter().map(|i| 2 * i)
}
には「具体的な型名を隠す」という効果があります.
ケース4:複雑な・不明な返り値の型名を省略する
Generics と同じように,impl Trait
もまた型名の省略に使えます.たとえば
fn iter_to_string(it: impl IntoIterator<Item = u64>) -> impl Iterator<Item = String> {
it.into_iter().map(|e| e.to_string())
}
fn do_something(it: impl Iterator<Item = String>) {}
let v: Vec<u64> = Vec::new();
do_something(iter_to_string(v));
というようなこともできます.
このことは,特にクロージャを返すときに顕著となります.
fn double() -> impl Fn(u64) -> u64 {
|x| 2 * x
}
クロージャの型名は分からないので,impl Trait
を使わない場合は
- トレイトオブジェクト
Box<dyn Fn(u64) -> u64>
を返す, - 関数
fn(u64) -> u64
として実装する
のいずれかの手段を取る必要がありました.すでに見た通り,impl Trait
なら簡単に解決できます.
ケース5:型の変更が可能かどうかを示す
何度もイテレータの例を挙げて恐縮ですが,次のコードを考えましょう:
use std::vec::IntoIter;
fn return_iter() -> IntoIter<u64> {
let v = Vec::new();
v.into_iter()
}
fn print_items(it: IntoIter<u64>) {
for n in it {
println!("{}", n);
}
}
関数 return_iter()
の中で Vec<u64>
を作り,そのイテレータの各要素を println!()
していく,というプログラムです.
このコードを書き換えたくなったとします.
上では Vec<u64>
を作っているのですが,何らかの理由で Vec<u64>
ではなく HashMap<u64, u64>
を作りたくなり,その結果返されるイテレータも HashMap<u64, u64>
の値に関するイテレータに変更が必要になりました.
そのような場合は,nightly
限定ではありますが,次のようになります:
#![feature(map_into_keys_values)]
use std::collections::hash_map::IntoValues;
use std::collections::HashMap;
fn return_iter() -> IntoValues<u64, u64> {
let m = HashMap::new();
m.into_values()
}
fn print_items(it: IntoValues<u64, u64>) {
for n in it {
println!("{}", n);
}
}
このようにいちいち std::vec::IntoIter<V>
を std::collections::hash_map::IntoValues<K, V>
に書き換えるのは,とても面倒です.
また,この書き換えによる影響範囲を見積もるのも大変です.つまり,「std::vec::IntoIter
では動くが std::collections::hash_map::IntoValues
では動かない関数」があるかもしれないので,そのような問題が起きないことを確認する必要もあるわけです.
たとえば
fn as_slice(it: &IntoIter<u64>) -> &[u64] {
it.as_slice()
}
のような関数があると,IntoIter<u64>
を IntoValues<_, u64>
に置き換えることはできませんね.
具体的な型ではなく impl Iterator
を返すプログラムであれば,as_slice()
のような関数が存在しないことを保証できます.
use std::vec::IntoIter;
fn return_iter() -> impl Iterator<Item = u64> {
let v = Vec::new();
v.into_iter()
}
fn print_items(it: impl Iterator<Item = u64>) {
for n in it {
println!("{}", n);
}
}
fn as_slice(it: &IntoIter<u64>) -> &[u64] {
it.as_slice()
}
let it = return_iter();
let _ = as_slice(&it); // Compilation error!!!
print_items(it);
もちろん,impl Iterator
ではなく自前のトレイトの impl MyTrait
を返すことも可能です.
まとめ
前半ということで,generics や impl Trait
の基本的な使いみちを挙げてみました.
後半では,実際のクレートを具体例にしながらより高度な用法を見ていきます.トレイトでしか実現できない機能ばかりなので,そちらもどうぞよろしくお願いします.
他にもこんなこともできるよ,なども紹介してもらえると,とっても嬉しいです.
ではまた後半でお会いしましょう.
Discussion