無駄と文化

実用的ブログ

Rust における関数とメソッドの使い分け

Rust ネタです。

Rust では構造体に対してメソッドを定義できます。
公式のドキュメント では Rectangle 構造体に対して面積を求める area メソッドを定義する例が紹介されています。

struct Rectangle {
    width: f64,
    height: f64,
}

impl Rectangle {
    /// 四角形の面積を求める
    fn area(&self) -> f64 {
        self.width * self.height
    }
}

fn main() {
    let rect = Rectangle { width: 3.0, height: 4.0 };
    assert_eq!(rect.area(), 12.0);
}

メソッドを定義する方法としてはシンプルで良い例なんですが、実際に自分でメソッドを書いていくと疑問が浮かんできました。
「これって普通の関数でも書けるよな?」と。

/// 四角形の面積を求める, 関数版
fn area(rect: &Rectangle) -> f64 {
    rect.width * rect.height
}

同じ処理がメソッドでも関数でも書けるとしたら、使い分けの基準は何でしょうか?一方にできて他方にできないことがあるのでしょうか?
今回はメソッドと関数の使い分けについて考えてみたことをまとめます。

 

TL;DR

  • メソッドも関数も可視性は同じ
  • メソッド呼び出しは自動借用・自動参照解決してくれるので便利
  • 多態を実現するために impl は必須
  • バイナリサイズは関数の方が小さい (?)
  • 二つのクラスにまたがるような操作は関数にしてしまう手もある

 

可視性

まず private とか protected とかのアクセス修飾子のある言語 (たとえば PHP とか) では、「private なプロパティにアクセスするためにメソッドとして定義するしかないよね」となります。

が、Rust においては可視性の取り扱い方が違うのでこの点は問題になりません。
Rust では module の 内か/外か によって可視性が制御されます。メソッドであれ関数であれ module の中からなら private なプロパティにアクセスできます。

struct Rectangle {
    // width も height も private なプロパティですが...
    width: f64,
    height: f64,
}

fn area(rect: &Rectangle) -> f64 {
    // 関数から問題なくアクセスできます
    rect.width * rect.height
}

 

自動借用・自動参照解決

メソッドとして実装するとドット演算子 . を使って rect.area() のように呼び出すことができます。
ドット演算子には自動借用・自動参照解決という便利な機能があります。

例えば

impl Rectangle {
    fn area(&self) -> f64 {
        self.width * self.height
    }
}

と定義した場合、(&rect).area() のように Rectangle の借用に対して呼び出すかと思いきや rect.area() のように呼び出せます。
これはドット演算子 . が左オペランドである rect を自動的に借用して area() を呼び出してくれるからです。

 

上記の例とは逆に、ドット演算子は参照解決も自動でやってくれます。

例えば Rectangle の参照の参照 ref_ref_rect: &&Rectangle が手元にあったとして、area() メソッドを ref_ref_rect.area() のように呼び出せます。
さきほどと同じくドット演算子 . が左オペランドである ref_ref_rect を自動的に参照解決してから area() を呼び出してくれるからです。

 

普通の関数呼び出しにはこのような機能はありませんから rect や ref_ref_rect に対して area() 関数を呼ぶときには

area(&rect);
area(*ref_ref_rect);

のようにしなければいけません。

そのためメソッドとして実装しておけば &* を気にせず雑に呼び出せて便利な気がします。
逆に、 Rust の型推論に慣れていない人は混乱のもとになるかもしれませんね。

 

多態

いわゆるポリモーフィズムというやつです。
Rust では多態を表現するのにトレイトを使います、そしてトレイトに型ごとの実装を与えるのに impl ブロックを使います。
そのため同じ名前で型ごとに異なる挙動を与えたくなったらメソッドとして実装するしかありません。

先ほどの Rectangle と area の例で云うと、area メソッド/関数が Rectangle 専用であるうちは何も問題は起きません。
Rectangle に加えて Circle や Triangle を扱い、area で面積を求めようと思うとトレイトとメソッドを持ちださざるを得なくなります。

実例として円を表現する Circle 構造体を追加で定義してみましょう。

struct Circle {
    radius: f64,
}

さらに Shape トレイトを定義します。

trait Shape {
    fn area(&self) -> f64;
}

さらにさらに Rectangle と Circle それぞれに area メソッドの実装を与えると、

impl Shape for Rectangle {
    fn area(&self) -> f64 {
        self.width * self.height
    }
}

impl Shape for Circle {
    fn area(&self) -> f64 {
        self.radius * self.radius * std::f64::consts::PI
    }
}

これにて area メソッドは Rectangle に対しても Circle に対しても呼び出せるようになりました。
area をただの関数として定義した場合、このように多態にすることはできません。

 

...と云いつつ、
Rectangle, Circle が Shape トレイトを実装している前提であれば、Rectangle と Circle のいずれかを引数にとって面積を返す関数は定義可能です。

fn area<S: Shape>(shape: &S) -> f64 {
    shape.area()
}

このように。

area を多態にするために impl を使うことは避けられませんが、最終的に area 関数を実装して公開することは可能です。

 

バイナリサイズ

バイナリに差は出るのでしょうか? 実際にコンパイルしてみました。

普通の関数で実装 メソッドで実装
最適化オプション無し 9,800 byte 10,096 byte
最適化オプションあり 8,024 byte 8,344 byte

macOS Ventura 13.5, rustc 1.71.1 でコンパイルしています。
メソッドで実装したときのバイナリサイズが 3%-4% ほど大きくなりました。

うーん、これは、どうなんですかね?

 

どの構造体に属させるのか迷う時は関数にしておくのが無難

多くの言語で配列の要素を連結して文字列にするために join を使います。

例えば JavaScript では、

["A", "B", "C"].join("|");  // => "A|B|C"

例えば Python では、

"|".join(["A", "B", "C"]) # => "A|B|C"

のように書けます。

さて、JavaScript と Python の例でレシーバと引数が入れ替わっていることに気づきましたか?
JavaScript においては Array クラスが join メソッドを持っています。Python においては str クラスが join メソッドを持っています。

私は Python を学び始めた頃に "|".join(["A", "B", "C"]) という書き方にとても違和感を覚えました。
が、よくよく考えると join メソッドは

  • 文字列化可能な要素の配列を引数に取ること
  • 結果が文字列になること

から配列に属するメソッドというよりは文字列に属するメソッドと考える方が自然なんじゃないかと思えてきました。

 

が、上記のような違和感はそもそも join を関数として定義してあげれば消え去ります。
実際、Perl や PHP においては join は関数です。

join("|", ["A", "B", "C"]);  // => "A|B|C"

二つのクラスにまたがり、かつ、頻出する操作は関数になっていたほうがいいんじゃないかなと思ったりします。

 

まとめ

私なりの結論は

  • 多態を実現するために impl は避けられない
  • が、そのような場合でも同様の関数を提供することはできる
  • 二つのクラスにまたがるような操作は関数にしてしまう手もある

という感じです。

みなさまからの鉞をお待ちしています。

 

 

私からは以上です。