はじめに
私が今一番好きな言語は Rust です。
Rust は書いていて楽しいですし、学びが多い言語だと感じます。
そこで今回は私が特に好きな Rust の特徴を紹介しつつ、Rust の良さを伝えていきたいと思います。
第 1 回目はこちらに,第 2 回目はこちらに,第 4 回目は間違ってこちらに公開済み1です、のでもしよろしければご覧ください。
今回の記事は私個人の意見であり、他の言語と比べた Rust の優位性を述べたいわけではありません。
あくまで好きなところ、書いていて楽しいところです。
第 3 回目は Rust の所有権/ライフタイム/借用ルールについてです。
Rust の中でも使いこなすのが相当難しい機能であり、私も好きではありますが、今でもミスをすることがあるものです。
しかし、この機能のおかげで Rust は高速性と安全性を両立させています。
また、所有権や借用ルールのおかげで設計がスマートになったり、ミスの少ない設計になるのではないかと個人的に思っています。
まずはそれぞれについて簡単に説明していきます。
所有権とは
プログラミング Rustでは以下のように説明されています。
Rust ではすべての値はその生存期間を決定する唯一の所有者を持つ
所有者が解放された場合、所有されていた値もドロップされる
少し難しく書かれていますが、要するに、値には所有者が絶対存在していて、その所有者が解放されたら値も解放されるということです。
例を見ていきます。
fn main() {
let s = "hello".to_string();
println!("{}", s);
}
まず、"hello".to_string()
で String 型の値を生成しています。
そしてlet s =
のところはs
に値の所有権を渡していると言えます。Rust ではこのことを束縛すると言います。
さらに値は生まれたスコープの中でしか生きておれず、スコープを抜けると解放されます。
この場合、main
関数の中でlet s = "hello".to_string();
としているので、main
関数が終了するとs
の所有権も解放され、s
の値も解放されます。
スコープを抜けると値は解放されるので以下のようなコードはコンパイルエラーになります。
fn main() {
{
let s = "hello".to_string();
};
println!("{}", s);
}
s
は main
関数の中のブロック内で束縛されているので、ブロックを抜けると解放されます。
println! のところではすでに解放済みのデータに対して処理が記述されているためコンパイルエラーが出ます。
また、所有権は移動することができます。
所有権の最初の説明でも書いてありましたが、Rust ではすべての値は唯一の所有者を持ちます。
つまり、所有権を所持できるのは変数一つだけです。
例えば以下のコードを見てください。
fn main() {
let s1 = "hello".to_string();
// s1の所有権がs2に移動
// s1は使えなくなる
let s2 = s1;
// コンパイルエラー
println!("{}", s1);
}
上のコードはコンパイルエラーになります!!
これは多くの言語とは異なる挙動なのではないでしょうか?
なぜコンパイルエラーになるかというと、s1
の所有権がs2
に移動しているためです。
所有権が移動すると、元の変数は使えなくなります。
これが多くの場合最初に Rust を触った人がつまづくポイントだと思います。
なかなか厳しいルールですが、このルールのおかげで Rust は高速性と安全性を両立させています。
こちらに関しては後述いたします。
ここでは以下のことがわかれば良いと思います。
- Rust の値には必ず所有者が存在する
- 所有権は移動可能
- 所有権は唯一であり、所有権が移動すると元の変数は使えなくなる
- 所有権はスコープを抜けると解放され、値も解放される
参照
前の説明では所有権について説明しましたが、所有権だけでは不便な場面があります。
例えば以下のコードを見てください。
fn main() {
let s = "hello".to_string();
let len = get_len(s);
// すでにsはget_lineへ所有権が移動しているためエラー
let upper = to_upper(s);
println!("{}:{}", upper, len);
}
fn get_len(s: String) -> usize {
...
}
fn to_upper(s: String) -> String {
...
}
get_len
関数とto_upper
関数はString
型を引数に取る関数です。
これらの関数は引数の所有権を奪ってしまうため、s
の所有権がget_len
関数に移動してしまい、その時点でs
は使えなくなります。
このような場面で使えるのが参照です。
参照は値の所有権を奪わずに値を借りることができます。
参照の実態はポインタであり、実体ではなくポインタのみを渡すため、所有権を奪わずに値を借りることができます。2
先ほどのコードを参照を使って書き換えると以下のようになります。
fn main() {
let s = "hello".to_string();
let len = get_len(&s);
let upper = to_upper(&s);
println!("{}:{}", upper, len);
}
fn get_len(s: &String) -> usize {
...
}
fn to_upper(s: &String) -> String {
...
}
上のように&
をつけることで参照を渡すことができます。
また、参照には&
と&mut
があります。
&
は不変な参照であり、値を変更することができません。&mut
は可変な参照であり、値を変更することができます。
とても便利な機能ですね。めでたしめでたし、、、とはいかないのが、Rust の借用ルールです。
借用ルール
Rust には借用ルールというものがあります。これは安全に参照を使うためのルールで、守らないとコンパイルエラーになります。
以下がルールです。
- 不変参照は複数存在できる
- 可変参照は一つのスコープに一つしか存在できない
- 不変参照と可変参照は同時に存在できない
- 参照は常に有効でなければならない
不変参照は複数存在できる
こちらは借用ルールの中でも比較的緩いルールです。
不変参照は複数存在できるため、以下のようなコードは問題ありません。
fn main() {
let v = vec!["hello".to_string(), "world".to_string()];
// vの不変参照がいくつあっても問題ない
for s in &v {
for s2 in &v {
for s3 in &v {
println!("{} {} {}", s, s2, s3);
}
}
}
}
上のコードはv
の不変参照を 3 つ取得していますが、全く問題なく実行できます。
可変参照はスコープにつき一つしか存在できない
こちらはなかなか厳しいルールです。
可変参照は一つのスコープに一つしか存在できないため、以下のようなコードはコンパイルエラーになります。
fn main() {
let mut v = vec!["hello".to_string(), "world".to_string()];
for s in &mut v {
// 2つ目の可変参照が存在するためコンパイルエラー
for s2 in &mut v {
// コンパイルエラー
s.push_str(s2);
s2.push_str(s);
println!("{}", s);
}
}
}
上のコードはv
の可変参照を 2 つ取得していますが、これはコンパイルエラーになります。
不変参照と可変参照は同時に存在できない
こちらも厳しいルールです。
fn main() {
let mut v = vec![String::from("hello"), String::from("world")];
for s in &mut v {
// 可変参照がすでに存在しているためコンパイルエラー
for s_ref in &v {
s.push_str(s_ref);
println!("{}", s);
}
}
}
上のコードのように不変参照と可変参照が同時に存在するとコンパイルエラーになります。
参照は常に有効でなければならない(値よりも長く生存してはいけない)
参照は値のポインタのようなもののため、参照が指す値が解放されてしまうと、参照も無効になります。
C や C++などの言語では解放済みの値に対してアクセスすると実行時にエラーになります。
つまり、参照は値よりも長く生存してはいけないということです。
Rust では値の寿命を管理するためにライフタイムという概念があり、Rust では解放済みの値に対してアクセスすることがないようにコンパイル時にチェックを行ってくれます。
つまり、参照は常に有効でなければならないということです。
例えば以下のケースを考えます。
fn main() {
let r;
{
let s = "hello".to_string();
r = &s;
}
println!("{}", r);
}
このコードはコンパイルエラーになります。r
は{}
の中で束縛されているs
の参照を持っているため、{}
を抜けるとs
は解放され、r
の参照も無効になるからです。
また時には以下のコードのようにライフタイムを明示的に指定する必要があります。
fn trim_first<'a>(s: &'a str) -> &'a str {
&s[1..]
}
fn main() {
//ok
let s = "hello";
let s2 = trim_first(s);
println!("{}", s2);
let s3;
{
let s4 = "";
// 参照元のs4がs3よりも短命なのでコンパイルエラー
s3 = trim_first(s4);
}
println!("{}", s3);
}
上のコードの'a
がライフタイムパラメータで、&'a str
は&str
型の参照で、その参照がどれくらい生存するかを示しています。
シングルクォートに文字を書くことでライフタイムを表せます。
上の例だと、引数に撮った参照のライフタイムと返り値のライフタイムが同じなため、返り値は少なくとも引数のライフタイム以上生存する必要があります。
a
であることに意味はなく、'a
はライフタイムパラメータを示す慣習的な書き方です。
2 つ以上書く場合はどちらが長命かなども表すことができます。
ライフタイムは一つだけ意味のある予約語があり、それは'static
です。
'static
はプログラム全体で生存するライフタイムを示します。
例えば文字列リテラルは'static
ライフタイムを持ちます。
なぜこれらの概念が必要なのか?どのようなメリットがあるのか?
ここまで所有権や借用ルールなどについて説明してきましたが、どれも厳しいものばかりで多言語にはなかなかなくデメリットばかりな気がします。
しかし、冒頭でも述べましたがこれらの概念があるおかげで Rust は高速性と安全性を両立させることができます。
次からはその理由について説明していきます。
所有権とライフタイムの必要性 ~Rust が高速である理由~
まず、Rust がなぜ高速であるかというと、その理由の一つに GC(ガベージコレクション)がないことが挙げられます。
GC とは、C,C++のように人間がメモリ管理を行うのではなく、プログラムが自動でメモリ管理を行う仕組みです。
メモリ管理を人間がしなくて良いため、プログラミング自体が楽になりますし、メモリ管理に関する致命的な問題を防ぐことができます。3
そのため多くのメジャー言語では GC が採用されています。
しかし、GC にもデメリットはあります。
その一つはメモリを自動で解放することによるオーバーヘッドです。
GC はメモリを自動で解放するため、以下のようなことが問題になることがあります。
- メモリの解放タイミングがコントロールしずらい
- メモリ解放の事前チェックが行われる
- メモリの解放に時間がかかる
- プログラムが一時停止することがある
- など...
処理が一瞬でも止まってしまうことは、人命などを扱うミッションクリティカルなシステムでは致命的な問題になります。
冒頭にも挙げましたが、Rust は GC が存在しない言語です。しかし C,C++のように人間がメモリ管理を行う必要もありません。
そのため、メモリ管理に対するヒューマンミスが少なくなりますし、GC によるオーバーヘッドがなく高速に動作します!
これが高速/安全性を両立させるための一つの要因です。
では GC なしでどうやってメモリ管理を行っているのかというと、それを可能にしている大きな要因が所有権やライフタイムだと思います。
人間がメモリ管理を行ったり、GC がメモリ管理を行う必要があるのは、他の言語では値がどのオブジェクトに所有されていて、いつ使われなくなるのか判断しかねるからです。
しかし Rust では所有者が必ずおり、また所有者は一人だけです。所有者がスコープを抜けると、値は自動で解放されます。
そして参照にはライフタイムという参照元がまだ生存しているのかどうかを示す情報があります。
これらの情報があれば、言語側で GC を必要とせずにメモリ管理を行うことができます。
このように、所有権やライフタイムのおかげでメモリ管理を Rust の責務にして安全にしつつも、高速で動作することができるのです。
可変参照が一つしか存在できないことによるメリット 1:データ競合を防ぐ
可変参照が一つしか存在できないことによるメリットでよく言われることはデータ競合を防ぐことができるということです。
この制約により並行性の問題などのデータ競合を解決することができます。
例えば以下の Go 4のコードを見てください。
package main
import "sync"
func main() {
var s string
wg := sync.WaitGroup{}
wg.Add(3)
go func() {
s = "hello"
wg.Done()
}()
go func() {
s = "world"
wg.Done()
}()
go func() {
s = "!"
wg.Done()
}()
wg.Wait()
println(s)
}
上のコードは 3 つのゴルーチンが並行に実行され、s
に値を代入しています。
しかし、どの goroutine の代入された値がprintln
で出力されるかわかりません。
もちろん上のコードは極端に最悪ですし、専用のツールを使えば race 状態であることは発見できます。
ここで言いたいのは Rust はこのようなデータ競合をコンパイル時に検知することができるということです。
言語レベルでデータ競合などの問題を解決しているということを伝えたいのです。
上のようなコードを Rust で書くと以下のようになります。
use std::thread;
fn main() {
let mut s = String::new();
// thread::spawnは可変参照を取ることができずエラー
let handle1 = thread::spawn(|| {
s = "hello".to_string();
});
let handle2 = thread::spawn(|| {
s = "world".to_string();
});
let handle3 = thread::spawn(|| {
s = "!".to_string();
});
let handles = vec![handle1, handle2, handle3];
for handle in handles {
handle.join().unwrap();
}
println!("{}", s);
}
thread::spawn
でスレッドを生成していますが、thread::spawn
は引数のクロージャの中に可変参照は取ることができません。5
これはデータ競合を発生させないようにthread::spawn
が設計されているからです。
そしてこの設計が可能なのは、不変参照と可変参照という参照を別の概念として言語設計し、借用ルールを設けているからです。
この場合はArc
やMutex
などを使ってデータ競合を防ぐことができますが、それはまた別の機会に説明できればなと思います。
可変参照が一つしか存在できないことによるメリット 2:データの変更が追跡しやすい
可変参照は不変参照と共存できないことや、可変参照は一つしか存在できないことから知らない間に参照が変更されるといったことが起きないのも魅力だと私は感じています
多言語だと様々なオブジェクトが参照を持っているため、知らない間にデータが変更されてしまっている、ということは多くの方が経験しているのではないでしょうか?
例えば以下のコードです。
package main
type Owner struct {
inner *string
}
func (o *Owner) addWorld() {
*o.inner += "world"
}
func main() {
s := "hello"
owner1 := Owner{inner: &s}
owner2 := Owner{inner: &s}
owner1.addWorld()
owner2.addWorld()
println(*owner1.inner)
println(*owner2.inner)
}
上のコードは 2 つの Owner が同じデータを参照し、更新しています。
もしかすると開発者はどちらもhello world
になると思っているかもしれませんが、実際はhello worldworld
になります。
上のような単純な場合であればすぐに気づけるかもしれませんが、複雑なオブジェクトが多くの参照を持っている場合、知らない間にデータが変更されていることに気づかないことがあります。また、そのデバッグはとても大変です。
Rust では可変参照が一つしか存在できないため、上のようなコードはコンパイルエラーになります。
struct Owner<'a> {
inner: &'a mut String,
}
impl Owner<'_> {
fn add_world(&mut self) {
self.inner.push_str("world");
}
}
fn main() {
let mut s = String::from("hello");
let mut owner1 = Owner { inner: &mut s };
// 2つ以上の可変参照が存在するとコンパイルエラー
let mut owner2 = Owner { inner: &mut s };
owner1.add_world();
owner2.add_world();
println!("{}", s);
}
これがコンパイル時に検出できるのはめちゃめちゃ良いのではないでしょうか?
また、可変参照は一つしか存在できないことと、&mut
と記述する必要があるということ(少し話がずれているかもしれないですが)によりデータ変更も追跡しやすくなります。
設計に与える影響
これまで紹介した所有権やライフタイム、借用ルールは今までの言語のルールと異なることから難しさもありますが、設計にも大きな影響を与えると思います。
例えば、複数の所有者が存在することは処理や構造が複雑になりやすいです。
そこに可変な処理が入ると、さらに難しく、デバッグも大変になりがちです。
これは設計レベルで問題があることが多くあるのではないかと思います。
Rust では所有権が一つしか存在できないことや、可変参照が一つしか存在できないことにより、上のようなことを言語レベルで防いでくれます。
結果として良い設計につながることが多いのではないかと思います。
ライフタイムに関しても同様です。
あまりに参照を多くの関数やオブジェクトに渡すと、ライフタイムのことを考えなくてはいけないので、設計が複雑になりがちです。
大概そのような時は参照を使い回すような設計や構造が良くないのではないかと思います。
このように Rust の厳しいルールに身を委ねると、結果的に良い構造になり、どの構造体がどの値を所持しているべきかなどうまく設計できることが多いと感じます。
一応 Rust にも参照カウントやスマートポインタなどの機能があり、あたかも一つの値を複数オブジェクトに所有させているように見せることもできます。
ですが、それを頻繁に使うことなく Rust のルールを守って設計することが良い設計につながるのではないかと思います。
少なくとも学びにはなると思いますし、これらのルールにぶち当たって達成できた瞬間が私はすきです。6
終わりに
今回は Rust の所有権/ライフタイム/借用ルールについて説明しました。
正直めちゃめちゃ説明するのが難しく、具体例を多く挙げられなかったのが今後の課題だなと思いました。
ただ、本当に所有権や借用ルールは面白く、学びがある機能だと思います。
ぜひ皆様も Rust を触ってみて、その良さを感じてみてください。
最後まで読んでいただきありがとうございました。
もし間違いなどありましたらご指摘いただけると幸いです。
-
間違えて公開したので、第 3 回にタイトルを修正しようとしましたが、第 4 回目は雑多なテーマだったので、第 3 回ではなく第 4 回としました。 ↩
-
ちなみに値が移動せず、コピーされるものもあります。参照はその一つであり、そのため参照はいくらでも作成し、渡すことができます。 ↩
-
今回は比較対象に Go を挙げましたが、Go が悪いことを言いたいわけではありません!単に私が簡単に比較のコードをかけたのが Go だったからです。私は Go も大好きですし、今回の比較コードはありえないような大袈裟な酷いコードです! ↩
-
実は可変参照が取れない以外にもライフタイムの問題などもありますが、わかりやすさのため省略しました。 ↩
-
タイトルが Rust の好きなところなのに最後の最後で好きというワードが出ましたね。。。 ↩