Edited at

Rustの構造体に文字列を持たせるいくつかの方法


きっかけ

Rust Programming Language Forumにこんな質問が出ていました。

https://users.rust-lang.org/t/idiomatic-string-parmeter-types-str-vs-asref-str-vs-into-string/7934


構造体にStringを持たせられるようにStringを受け取りたいです。これを実現する方法はいくつかあります。


  • &str

  • String

  • T: Into<String>

  • T: AsRef<str>

例:

pub struct Thing {

name: String,
}

impl Thing {
pub fn new(name: WhatTypeHere) -> Thing {
Thing { name: name.some_conversion() }
}

どれが一番Rustらしい書き方ですか?


単純に構造体に文字列を持たせる方法はいくつかあるのですが、実はその実装方法によって文字列のコピーやアロケーションの回数が異なります。

ここでは、上記の4つの場合と、コメント欄で提案されているT: Into<Cow<'a, str>>を使った方法をそれぞれ比較してみます。


質問者が提示する4つの方法


&str

&strを使う実装というのは、2通り考えられます。


パターン1

pub struct Thing {

name: String
}

impl Thing {
pub fn new(name: &str) -> Thing {
Thing { name: name.to_string() }
}
}

この方法では、文字列への参照を受取り、そこから文字列をコピーしてThingに持たせます。

つまり、Thing::new()を呼び出す度にアロケーションとコピーが発生します。

たとえ呼び出し側においてname変数が不要であっても、必ずその変数をコピーするということを意味します。

fn main() {

let name: String = fetch_name();
let person = Person::new(&name); // => nameの値をコピーする

// これ以降はname変数は不要
}


パターン2

では、アロケーションを避けるにはどうすれば良いでしょうか?

単純な方法としては、Thing構造体に参照を直接持たせる方法が考えられます。

pub struct Thing<'a> {

name: &'a str
}

impl<'a> Thing<'a> {
pub fn new(name: &'a str) -> Thing<'a> {
Thing { name }
}
}

しかし、この方法はまず使われません。

なぜなら、nameの寿命がPersonより短い時、ライフタイムチェッカーに引っかかってコンパイルできなくなるからです。

// これはコンパイルエラー

fn get_person() -> Person {
let output: String = ExternalCommand::run();
for line in output.lines() {
if line.starts_with("name: ") {
return Person::new(&line[6..]);
}
} // <- ここでlineの寿命が切れる

...(省略)
}


String

それならば、参照の場合だけコピーして、不要なString変数を持っている時はコピーしなければ良い、という発想に至ります。

pub struct Thing {

name: String
}

impl Thing {
pub fn new(name: String) -> Thing {
Thing { name }
}
}

しかし多くの場合、文字列は&str型で保持しているのが普通です。

この方法では呼び出し側が明示的にString型に変換する必要があるため、Personを多く生成しようとするとコードが一気に煩雑になります。

fn main() {

let sato = Person::new("sato".to_string());
let suzuki = Person::new("suzuki".to_string());
let tanaka = Person::new("tanaka".to_string());
}

また、Rustにおいては変数の型がStringであるか&strであるか分かりにくいケースも存在しますが、この関数では呼び出し側が型変換に関するすべての責任を負わなければなりません。


T: Into<String>

そこでGenericsの登場です。Genericsを上手く使ってString型と&str型を両方受け取り、関数内で型変換を行います。

pub struct Thing {

name: String
}

impl Thing {
pub fn new(name: impl Into<String>) -> Thing {
Thing { name: name.into() }
}
}

これならば、呼び出し側が文字列の型をいちいち気にする必要はありません。

fn main() {

let name = "sato";
let person = Person::new(name);

let name = "sato".to_string();
let person = Person::new(name);
}


T: AsRef<str>

&strのGenericsバージョンです。

この方法も毎回コピーが発生します。

pub struct Thing {

name: String
}

impl Thing {
pub fn new(name: impl AsRef<str>) -> Thing {
Thing { name: name.as_ref().to_string() }
}
}

AsRefトレイトについてはこの記事が詳しいです。


ちょっと待った!

一見すると、パフォーマンス面で優れているのはStringT: Into<String>を使った方法のように見えますが、実はさらにコピーを減らすことが出来ます。

具体的にどういったケースで無駄なコピーが発生しているかと言うと、引数がPersonオブジェクトよりも長い寿命を持っている場合です。

例えば、

fn main() {

let person = Person::new("sato");
}

という文において、"sato"の型は&'static strです。つまりその寿命はプログラムが終了するまで生き続けており、わざわざコピーせずともその変数を参照すればよいということになります。

そのための方法が、今回の回答欄でも提案されているCow<&'static, str>を使ったイディオムです。

pub struct Thing {

name: Cow<'static, str>
}

impl Thing {
pub fn new(name: impl Info<Cow<'static, str>>) -> Thing<'a> {
Thing { name: name.into() }
}
}

Cowとは、参照型と所有型のいずれかを保持することが出来るオブジェクトです。

つまり、一つの変数でデータを参照することも、変数を所有することも出来ます。

上記のプログラムは、Person::new()メソッドに渡す変数の型によって以下のように異なる挙動を示します。



  • String => 引数の所有権がmoveされ、Thing構造体がその所有権を持つようになる(ゼロコピー)1


  • &'static str型(つまり文字列リテラル) => 引数を参照する(ゼロコピー)2


  • &strまたは&String => トレイト境界を満たしていないのでコンパイルエラー

つまり、Person::new()メソッド内においては一度もコピーが発生しないことになります。

ただし、staticな寿命を持たない参照変数を渡す時は、呼び出し側が明示的にその変数をコピーする必要があります。

fn main() {

let sato = Person::new("sato"); // <= 文字列を参照
let suzuki = Person::new("suzuki".to_string()); // <= 文字列の所有権を移譲
let person = get_person();
}

fn get_person() -> Person {
let output: String = ExternalCommand::run();
for line in output.lines() {
if line.starts_with("name: ") {
return Person::new(line[6..].to_string()); // <= 文字列を明示的にコピー
}
} // <= ここでlineの寿命が切れるが、中身はコピーしているので問題なし

...(省略)
}


結局どれが良いのか

競プロなどPerformanceを第一に考える場面では、Cowを使うケースもあるかもしれません。

しかし実用上においては、Cowを使用した書き方はあまり好ましくないと思われます。

第一に、Cowは文字列を所有しているのか参照しているのかがコンパイル時に分からないため、スレッドなどで並行処理を行う際に余計なリスクが発生する場合があります。

場合によっては、全く意図しないタイミングでPersonnameの値が書き換わったり、読み込みと書き込みが同時に行われることでもはや出力は予測できないものになることも考えられます。

第二に、設計の変更などでnameフィールドをmutableに変更したくなった場合、Cowを使った方法ではプログラム全体を大々的に書き換える必要が生じます。

文字列リテラルは&'static strなのであって&'static mut strではありませんから、文字列リテラルを渡すときも結局コピーする必要が出てきます。

したがってプログラムの保守性という観点から見ても、Cowを使うのはあまり好ましい選択とは言えません。

第三に、呼び出し側が変数の寿命を意識する必要があるというデメリットにも関わらず、Cowを使うことによって得られるメリットはほんのわずかなPerformanceの向上だけです。

実用プログラミングにおいては開発効率や保守性などのほうが優先されますし、特にPerformanceというのは開発がある程度安定してから徐々に変更を加えていく程度で良いと考えています。

もちろんプログラムを書く目的にもよるので一概にそうとは言い切れませんが、少なくとも実用プログラミングにおいては、開発初期の段階でStringまたはT: Into<String>の方法を利用し、設計が安定してmutableにする必要がなくなり、ほんの僅かでもPerformanceを向上させたい場合のみ、Cowを使うのが良いのではないでしょうか。