😎

Rust のトレイトを使おう!(1)

2021/04/30に公開

みなさんこんにちは.突然ですが,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") のように &strString としてファイルパスを持っているはずで,ユーザが自分で 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 として画像データを書き込み
}

(もしかしたら RequestWrite ではなく 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