引数をめぐる議論

Rust の引数まわりは素朴だが、Ruby の redirect_to は文字列もモデルもハッシュも受け取る。コーディングエージェントと向き合うなかで、十数年持ち続けた名前付き引数への見方が変わった。

日本語
コピー
浅色背景上并排两段 Rust 代码,展示同一个函数在具名参数与位置参数下的不同调用写法

Rust のコードはこうなっている:

// define a function
fn foo(x: i32, y: i32) -> i32 {
 // body elided
}

// call it
let z = foo(5, 6);

プログラミング言語の用語では、x と y を仮引数、5 と 6 を実引数と呼ぶ。

Rust は仮引数と実引数に関してこれといった凝った機能を持っていないが、他の言語にはある。たとえば、Ruby の関数はこうなっている。Ruby は私がかつて大量にコードを書いた言語だ:

# its definition in Rails
def redirect_to(options = {}, response_options = {})
 # body elided
end

# you can call it in all of these ways:

# pass a string with a URL
redirect_to "http://www.rubyonrails.org"

# pass an instance of a model to go to it, if post.id = 5 then this might go to
# "/posts/5"
redirect_to @post

# invoke an action directly, might be equivalent to the above in a Posts controller
redirect_to action: "show", id: 5

# redirect to a model's URL, passing a flash message
redirect_to post_url(@post), alert: "Watch it, mister!"

# do the same but also set a specific HTTP code
redirect_to post_url(@post), status: :found

# do both
redirect_to post_url(@post), status: 301, flash: { updated_post_id: @post.id }

私はかつて Ruby が好きで、今は Rust が好きだ。正直に言えば、redirect_to のようなものこそ、私が Rust にこうした凝った機能を入れることに強く反対する大きな理由のひとつだ。非常に簡潔に書けて、ある種の目には美しくさえ映る。だが、その関数が一体何をするのかを把握するのが極めて難しくなる。基本的には誰かが良いドキュメントを書いてくれることを期待するしかなく、Rails のドキュメントはかなり良いものの、すべてのライブラリにあるわけではない。

ここで断っておくと、実際にはいくつかのものが束になっている。柔軟な実引数の型、options hash、デフォルト値、そしてキーワードに見える構文だ。私が疑念を抱くのはこの API スタイル全体であって、実引数ラベルそのものではない。

では、これらの機能とは具体的に何なのか。

名前付き引数

最も単純なのは名前付き引数だ。名前のとおり、呼び出し側で引数名を明示的に書けるようにする:

// without named parameters
let z = foo(5, 6);

// if Rust had them
let z = foo(x: 5, y: 6);

名前付き引数の利点は、呼び出し側でより多くの情報が得られること、そして柔軟性が増すことだ。言語によっては、名前付き実引数を異なる順序で渡せるものもある:

// since we have names, we can write this:
let z = foo(x: 5, y: 6);

// but we can also write this:
let z = foo(y: 6, x: 5);

欠点は、かなり冗長になりうることだ:

# FastAPI in Python, UserOut class not shown
@app.get(
 "/me", 
 response_model=UserOut, 
 status_code=200, 
 tags=["Users"],
 summary="Get current user profile"
)
def get_current_user():

極端な例で藁人形を作るのは避けたが、get は 23 個の異なるキーワード実引数を受け取れるので、実際に書くととてつもなく長くなりうる。

式を渡すときには名前付き実引数が役立つことがある。呼び出し側でより多くのことが読み取れるからだ。上の例では、status_code=200 は裸の 200 よりずっと良い。HTTP に馴染みがあればそれが何かはすぐ分かるが、それでもこの追加の明瞭さは悪くない。ただ、本当に複雑な例はたいてい変数に切り出してから関数に転送されるので、そうなると冗長かつ余計になる。FastAPI 内部の例を見てみよう。ここではすべてのオプションを渡して get を直接呼んでいる:

self.router.get(
 path,
 response_model=response_model,
 status_code=status_code,
 tags=tags,
 dependencies=dependencies,
 summary=summary,
 description=description,
 response_description=response_description,
 responses=responses,
 deprecated=deprecated,
 operation_id=operation_id,
 response_model_include=response_model_include,
 response_model_exclude=response_model_exclude,
 response_model_by_alias=response_model_by_alias,
 response_model_exclude_unset=response_model_exclude_unset,
 response_model_exclude_defaults=response_model_exclude_defaults,
 response_model_exclude_none=response_model_exclude_none,
 include_in_schema=include_in_schema,
 response_class=response_class,
 name=name,
 callbacks=callbacks,
 openapi_extra=openapi_extra,
 generate_unique_id_function=generate_unique_id_function,
)

この例では実はもう二つの機能をこっそり混ぜている。ある関数が 23 個の引数を持っているのに、なぜ 5 個しか渡していないのか?

省略可能引数とデフォルト引数

名前のとおり、省略可能引数は引数を渡さないという選択を可能にし、デフォルト引数は引数が渡されなかったときにデフォルト値を設定できるようにする。

例に戻ろう:

@app.get(
 "/me", 
 response_model=UserOut, 
 status_code=200, 
 tags=["Users"],
 summary="Get current user profile"
)
def get_current_user():

22 個ありうる引数のうち 5 個しか渡していない。毎回 22 個すべてを渡さなければならないとしたら、あまりに冗長だ。そこで一部の引数にデフォルト値を宣言しておけば、呼び出し側でそれらを省略し、渡さずにデフォルト値をそのまま使える。Ruby で見たとおりだ:

# its definition in Rails
def redirect_to(options = {}, response_options = {})

= {} は options と response_options のデフォルト値で、空の hash map だ。

省略可能引数とデフォルト引数はしばしば一緒に現れる。何しろ、ある引数を渡さなかったとき、その仮引数はどんな値を取るのか? 言語に何らかの null 値があればそれが一つの解決策になり、デフォルト値のない省略可能引数のように使える。

だが次の機能を使えば、片方だけを本当に持つことができる。関数オーバーロードだ。

関数オーバーロード

関数オーバーロードをサポートする言語では、同じ名前でシグネチャの異なる関数を複数定義できる。どの関数が呼ばれるかは、どの引数を渡したかで決まる。これにより、デフォルト値のない省略可能引数を本当に持てる。Java で書くと:

// Version 1: Requires both arguments
void connect(String url, int timeout) { ... }

// Version 2: Timeout is optional to the caller, and handled internally
void connect(String url) { ... }

ここで timeout を渡せば最初の関数が、渡さなければ二番目の関数が呼ばれる。引数は null になるのではなく、そもそも存在しない。

これは「関数ディスパッチ」の一形態だ。平たく言えば「ある関数が呼ばれたとき、具体的に何が呼ばれるのか?」ということだ。やり方は非常に多く、この記事で詳しく扱うつもりはないが、可能性は実にさまざまだ。静的ディスパッチと動的ディスパッチ、単一ディスパッチと多重ディスパッチ、述語ディスパッチ、パターンマッチディスパッチ、プロトタイプベースのディスパッチ……いずれ別の記事で書くかもしれない。

では、ここでの利点と欠点は何か。

これらの機能が私を不安にさせる

前述のとおり、Rust は現在この三つの機能のいずれもサポートしていない。そして何年もの間、コミュニティはその追加を求めてきた。ここには 12 年前の GitHub issue があり、このサポートが議論されている。そこでは我々が触れていないものまで言及されている。「可変長引数リスト」だ。

これがこれらの機能に対する私の第一の不満につながる。それらはあまりに多く、しかも互いに絡み合っている。先に述べたように、省略可能引数があればデフォルト引数も欲しくなるだろう。名前付き引数があれば、名前付きだけをサポートしたいのか、両方をサポートしたいのか。全部サポートするのはあまりに誘惑的で、そうして複雑さの大きな山を築いてしまう。

今の Rust では、名前を少しずつ変えた複数の関数を書くしかない:

// a new empty vector
let v = Vec::new();

// one with a set capacity
let v = Vec::with_capacity(5);

代償は「関数に二つの異なる名前を付ける」ことだけだ。その代わりに得られるのは極めて単純なルールだ。関数定義は一つだけ、名前で呼ぶ。それで終わり。次のように書けたらいいだろう:

let v = Vec::new();
let v = Vec::new(5);
let v = Vec::new(capacity: 5);

かもしれない。だが私に言わせれば、代償は非常に高い。これらの機能に慣れている人なら、代償はそれほど高くないと感じるかもしれない。しかし私は Ruby を体に刻んでいる。あまりに多くの惨状を見てきた。だからこそ、この点における Rust の簡潔さが次第に好きになっていった。確かに、関数に大量の引数があるなら builder を書くことになるかもしれず、builder にはそれ自体の冗長さがある:

// if Vec had a builder
let v = Vec::new()
 .with_capacity(5)
 .build();

// all of the code you have to write to make the builder elided

だが言語そのものは単純なままで、ルールも分かりやすい。すでに複雑だと見なされている言語にとって、これが正しいあり方だとずっと考えてきた。だからこそ、こうした形で Rust を拡張しようとするさまざまな提案に、ここ何年も反対し続けている。

ただ最近、僕はひとつの機能について考えを変えた。それもこれ一つだけ。もし Rust がこれを取り入れたら、受け入れてもいいと思える。たぶんね。あと断っておくと、僕は何年も Rust の開発から離れているので、僕の意見は実際のところどうでもいい。でも構わない。ここは僕のブログだし、意見というのは表明するためにある。

今なら名前付き引数を受け入れられる

Rust に名前付き引数があってもいいと思う。ただし、省略可能引数やデフォルト引数は認めない。僕の考えを本当に変えたのはコーディングエージェントだ。申し訳ない、AI の話はもう聞き飽きているかもしれないが、もう少し付き合ってほしい。

前述のとおり、名前付き引数に対する僕の不満のひとつは冗長さだった。だがその冗長さが引き換えにしているのは明快さだ。冗長さと明快さが常に同じものだとは思わない。僕は do/end よりも {} のほうが好きだし、個人的には読みやすいとも思う。ただ、image crate のこの関数を見てほしい:

// definition
pub fn crop_imm(
 image: &I,
 x: u32,
 y: u32,
 width: u32,
 height: u32,
) -> SubImage { ... }

// calling it
let cropped = image::imageops::crop_imm(&img, 10, 20, 200, 100);

// if we had named arguments
let cropped = image::imageops::crop_imm(
 image: &img,
 x: 10,
 y: 20,
 width: 200,
 height: 100,
);

これは確かに可読性を大きく上げる。疑いようがない。だが僕にとっては、これを全部打ち込む価値はとてもじゃないが釣り合わない。しかし Claude が書くのなら? それならそれほど気にしない。しかも、人間にとっての可読性の利点は、エージェントにとってはもっと強い。呼び出し地点に増えたこの明快な文字列は、(ちゃんとした評価はまだしていないが……)むしろ役に立ち、消費するトークンも呼び出しも少なくて済みそうだ。エージェントが上の名前付き引数の例を読めば、関数シグネチャを調べなくても 10 が x の値だと分かる。「人間に良いものはエージェントにも良い」がまたしても当てはまる。

これが、僕が今も省略可能引数に反対する理由でもある。あれは関数に渡されるものを意図的に隠し、呼び出し地点を不明瞭にする。利点はタイプ量が減ることだ。だが僕はもう自分でタイプしていない。つまり利点は消え、欠点だけが残る。FastAPI の例で foo=foo を渡す冗長さも解決しない。あれは依然として冗長だ。だが欠点のほうは、いくらか緩和されたことになる。

注目すべきは、この理屈が builder パターンにもそのまま当てはまることだ。だから僕は Rust では builder をできるだけ使わない。builder は名前付き引数を模倣できる。それは良いことだ。だが省略可能引数・デフォルト引数・可変長引数も模倣できてしまう。それが悪い。この手間が割に合うこともあるが、毎回ではないし、おそらくほとんどの場合は割に合わない。

これで最大の欠点は消え、最大の利点は残る。悪くない。だが、この機能を気に入ったとしても、Rust に固有の現実的な問題が山ほどあり、言語に加えるのは簡単ではない。

だが現実的な問題が山ほどある

「この機能は抽象的に良いか」と「この機能を実装すべきか」はまったく別の話だ。名前付き引数の実装には大量の未解決問題と欠点がある。単純な問題をひとつ挙げれば、既存の関数を消すことはできない。with_capacity は永遠に残る。後に続くより難しい問題に比べれば、これはまだ小さな欠点だ。

まず、厳密に言えば Rust の引数は名前ではなくパターンだ。名前は単なる単純なパターンにすぎないが、もっと複雑なものも書ける:

fn foo((x, y): (i32, i32)) {

この引数に名前はない。ふたつの束縛を導入するパターンだ。外部名と内部名を区別する何らかの構文を設計することはできるが、また複雑さが顔を出す。

関数が値になるときはどうする? 今でもこう書ける:

fn resize(width: u32, height: u32) {
 // body elided
}

fn offset(dx: u32, dy: u32) {
 // body elided
}

let f: fn(u32, u32) = if resizing { resize } else { offset };

f の引数はどんな名前を使うべきか? それぞれに名前を付けられる。これはエラーになるのか? f を呼ぶとき、どんな名前が使えるのか? もっと厄介なことに、Rust は今こういうコードも実は受け付ける:

type Callback = fn(width: u32, height: u32);

fn f(g: Callback) {}

fn bar(x: u32, y: u32) {}

fn main() {
 f(bar) ;
}

Callback の名前は単なるドキュメントで、ほら、必須ではない。これはエラーになるのか? bar に名前を変えさせるべきか? trait 定義も実は同じだ:

trait Writer {
 fn write(&mut self, data: &[u8]);
}

struct Sink;

impl Writer for Sink {
 fn write(&mut self, bytes: &[u8]) {
 // body elided
 }
}

これはエラーか? それとも問題ないのか?

もうひとつ問題がある。評価順序だ。

fn consume(data: Vec, length: usize) {
 // body elided
}

現在 Rust は呼び出し地点で常に引数を左から右へ評価する。では名前付き版では:

fn consume(data: Vec, length: usize) {
 // body elided
}

fn consume2(length: usize, data: Vec) {
 // body elided
}

fn main() {
 let data = vec![1, 2, 3];

 // this is an error, borrow after move
 consume(data, data.len());
 // this is okay
 consume2(data.len(), data);

 // does this compile or not?
 consume(length: data.len(), data: data);
}

定義どおり左から右への評価順序を保ちつつ、名前付きの値を任意の順序で使えるようにしたら、呼び出し地点は突然、ある順序ではコンパイルが通り、ある順序では通らなくなる。評価順序を「コンパイルが通ればよし」に変えるのか? これは非常に危険で混乱を招くと感じる。

Rust は構造体のフィールドではこれを実際に許している:

struct Args {
 data: Vec,
 length: usize,
}

fn main() {
 let data = vec![1, 2, 3];
 
 // works
 let args = Args {
 length: data.len(),
 data,
 };

 // doesn't
 let args = Args {
 data,
 length: data.len(),
 };
}

名前付き引数に関する多くの提案が、これを struct と統一しようとするのは、理由のひとつがここにある。前に言ったように、克服不可能ではない。ただ、片づけるべきことがいくつかあるだけだ。

もうひとつ小さな問題は互換性のリスクだ。いったん引数に名前を付け始めると、名前を変えると呼び出し側が壊れる。これは受け入れられるか? エイリアスを導入して緩和できるか? ますます複雑になる……

これらは設計空間から出てくる問題のほんの一部で、この記事はすでに長すぎるので、ここまでにしておく。だがこれをやろうとする提案は、これらすべて、そしてそれ以上に向き合わなければならない。言語設計が難しいのはそういうことだ。

結論

世界が変われば、あるいは世界に対する僕の理解が変われば、自分の意見も時間とともに変わるだろうと思っている。だから、十数年持ち続けた意見が最近少し変わったことを共有するのは、多少面白いかもしれない。名前付き引数が Rust に合うかどうか、僕はまだ確信がない。だが少なくとも今は、このアイデアに対して多少なりとも開かれている。もし本当にこれを推し進めようとする人がいるなら、あとは言語チーム次第だ。そして実際に推している人はいる。たとえば、この記事を書いた理由の半分は、今日この投稿を見たことだ。この記事はそれを支持も反論もしていない。実はまだ読んでいない。ただ最近ずっとこのことを考えていて(Rue を作りながら)、それが流れてくるのを見て、考えをいくつか書き留める気になった。君も見てくるといい。あれは本物の提案で、僕のこれはどちらかといえば、このおおまかなアイデアと僕個人の見方についての考察だから。

出典: Steve Klabnik← ホームへ戻る