名前付き引数と省略可能な引数は素晴らしい
Rust には名前付き引数も省略可能な引数もない。Dart、C#、TypeScript、Python の実装を並べながら、この欠落が何をもたらすのか、どう実装しうるのかを検討する。
日本語
コピー
Rust で一番気に入らない点は何かと聞かれるたび、答えは決まっている。名前付き引数と省略可能引数がないことだ。
私が許容できるプログラミング言語は多くない。Rust、C#、TypeScript、Dart、Python だ。この中で名前付き引数を出すのが一番面倒なのが Rust だ。この記事ではまず、他の言語が名前付き引数をどう実現しているかを見ていく。そのうえで Rust を見て、名前付き引数がないことで何が困るのか、将来どんな方法で導入されうるのかを考える。
他の言語は名前付き引数をどう扱うか
Dart
Dart は GUI 向けに設計された。GUI コンポーネントは省略可能な引数が大量に付きものなので、Dart はこの点に力を入れている。今日取り上げる言語の中では、間違いなく一番よくできている。
まず普通の関数を見て、この言語に慣れておこう。
Widget Text(String text) {
// ...
}
言語一般の水準で言えば、ごく普通だ。Dart を知らなくても、何をしているかはだいたい想像できるだろう。Dart は要するに、プログラマがとっつきやすいように実験室で設計された言語だ。
ここで文字サイズを設定する名前付き引数を足し、デフォルト値を 16 ピクセルにしてみる。
Widget Text(String text, {int fontSize = 16}) { /* ... */ }
// usage:
Text("Hello, world!", fontSize: 24)
名前付き引数は波括弧で囲み、見た目は map に少し似ている。デフォルトを null にすることもできるし、必須にすることもできる。
Widget Text({
int fontSize = 16,
Color? color,
required String text,
});
Dart は省略可能な位置引数もサポートしていて、引数を角括弧で囲むだけだ。
// From the Dart documentation
String say(String from, String msg, [String device = 'carrier pigeon']) {
var result = '$from says $msg with a $device';
return result;
}
assert(say('Bob', 'Howdy') == 'Bob says Howdy with a carrier pigeon');
assert(say('Bob', 'Howdy', 'smoke signal') == 'Bob says Howdy with a smoke signal');
C#
C# の名前付き引数の扱いもかなり良い。実のところ、どの引数にも名前を付けられる。
void ExampleMethod(string foo, string bar)
{
Console.WriteLine($"{foo} {bar}")
}
ExampleMethod(bar: "Hello", foo: "world");
どの引数にもデフォルト値を付けられる。必須引数の後ろに書けばいい。
void ExampleMethod(string foo, string bar = "world")
{
Console.WriteLine($"{foo} {bar}")
}
ExampleMethod("Howdy");
これだけだ。この簡潔さが気に入っている。
TypeScript
私の見るところ、TypeScript の名前付き引数の扱いは良くない。ただ、省略可能な位置引数は名前こそないものの使い勝手がいいので、そこから始める。
function example(required: string, optional?: string = "world"): string {
return `${required} ${optional}`;
}
example("Hello") === "Hello world";
example("Hello", "TypeScript") === "Hello TypeScript";
これは問題ないと思う。デフォルト値を指定しなければ undefined になる。問題は、TypeScript に使いやすい名前付き引数の構文が組み込みでなく、他の機能で無理やり作るしかないことだ。
function example(
required: string,
{
requiredNamed,
namedOptional = "world"
}: { requiredNamed: string, namedOptional?: string }
): string {
return `${required} ${requiredNamed} ${namedOptional}`;
}
example("Hello", { requiredNamed: "beautiful" });
ここでは匿名型と分割代入を同時に使っている。関数の第 2 引数は requiredNamed と namedOptional の 2 つのフィールドを持つ匿名オブジェクト型だ。そしてこの引数を分割代入して、関数内でこれらのフィールドを使えるようにする。
一番うんざりするのは、フィールド名を 2 回書かされることだ。1 回は型のために、もう 1 回は関数の引数のために。この冗長さは、まともな言語なら避けるべきものだ。関数呼び出しも、あの波括弧のせいで必要以上に長くなる。
残念ながら、この書き方は React のあちこちにある。コンポーネントは普通、単一のオブジェクト引数を受け取る関数なので、コンポーネントを定義するたびにこれを繰り返し書く羽目になる。
Rust
Rust で名前付き引数を実現しようとする試みはあるが、概して出来が良くない。最もよく見るのは、関数用に struct を定義し、それに Default trait を実装させて、struct の初期化を ..Default::default() で終えられるようにする方法だ。
struct ExampleParams {
foo: String,
bar: String,
}
impl Default for ExampleParams {
fn default() -> Self {
Self {
foo: "Hello".into(),
bar: "Hello".into(),
}
}
}
fn example(Example { foo, bar }) -> String {
format!("{foo} {bar}")
}
assert_eq!(
example(Example { foo: "Hello".into(), ..Default::default() }),
"Hello world".to_string()
);
先ほど TypeScript の書き方の冗長さを嘆いたが、これはもっとひどい。型が匿名ではないからだ。さらに厄介なことに、デフォルト値が型のデフォルト値でない場合(たとえば String の空文字列)、デフォルト値を設定するためだけに関数を丸ごと書く必要がある。しかもこれが使えるのは、すべての引数にデフォルト値がある場合だけだ。一部の引数にしかデフォルト値がなければ、もっと冗長な方法が要る。
なぜ Rust に省略可能引数が必要か
標準ライブラリは上のパターンに従う傾向がない(私の見方では、それでいい)。それでも、これを使えばもっと良くなる場面はある。
HashMap のいくつかのファクトリメソッドを例に取る。
impl HashMap {
pub fn new() -> HashMap;
pub fn with_capacity(capacity: usize) -> HashMap;
}
impl HashMap {
pub fn new_in(alloc: A) -> Self;
pub fn with_capacity_in(capacity: usize, alloc: A) -> Self;
}
impl HashMap {
pub const fn with_hasher(hash_builder: S) -> HashMap;
pub fn with_capacity_and_hasher(capacity: usize, hasher: S) -> HashMap;
}
impl HashMap {
pub fn with_hasher_in(hash_builder: S, alloc: A) -> Self;
pub fn with_capacity_and_hasher_in(capacity: usize, hasher: S, alloc: A) -> Self;
}
省略可能な引数がわずか 3 つあるだけで、すべてのオーバーロードを表すのに 8 つの関数が要る。Rust には実質的にオーバーロードがないので、各関数にそれぞれ名前を付けなければならない。しかも、そうした複雑なファクトリメソッドを使うときは、プログラマやコードレビュアーが引数の順序を覚えるか調べるかしなければならない。
もう一つの問題は、引数に名前がないと間違えやすいことだ。次の print 関数を見てほしい。
fn print(text: &str, bold: bool, italics: bool, underline: bool);
ここでは引数に名前がないので、取り違えやすい。italics 引数が 2 番目ではなく 3 番目だと私が思い込めば、うっかり文字を太字にしてしまうかもしれない。
もともと引数が多い関数もある。ここに色、下線の色、背景色、点滅文字、隠し文字、囲み文字、高速点滅文字を足すとしよう。14 個の引数を関数に渡したい者などいない。しかもそのうちいくつかは妥当なデフォルト値を持つ。そのうえ、その構造体が 1 つの関数のためだけに存在するのなら、名前を付ける理由は見当たらない。
名前付き引数の各種提案
まず、私が見てきた Rust の名前付き引数を改善する提案をいくつか紹介する。いずれも気に入っていない。その後で、私が好む案を明かす。
デフォルトフィールド値
この機能は Nightly Rust で既に使える。syn も最近これに対応した。Default trait を実装するための特別な構文を追加し、それを容易にするという発想だ。この提案自体が名前付き引数の問題を解決するわけではないが、より大きな案の一部にはなりうる。
#[derive(Default)]
struct Pet {
name: Option, // impl Default for Pet will use Default::default() for name
age: i128 = 42, // impl Default for Pet will use the literal 42 for age
}
// Pet { name: Some(""), age: 42 }
let _ = Pet { name: Some(String::new()), .. };
// Compilation error: `name` needs to be specified
let _ = Pet { .. };
// Pet { name: None, age: 42 }
let _ = Pet::default();
この提案そのものに異論はない。私自身、これを polyfill するライブラリを書いた(feluments)。構造体に使う分には完璧に見える。だが関数にはほとんど役立たない。
構造体レコード
デフォルトフィールド値の RFC は、却下された構造体レコードの RFC に言及している。構造体レコードは、匿名フィールドの構造体、あるいはフィールド名付きのタプルと考えればいい。
fn do_stuff_with(color: { red: u8, green: u8, blue: u8 }) { // More ergonomic!
some_stuff(color.red); // *And* readable! :)
...
other_stuff(color.green);
...
yet_more_stuff(color.blue);
}
do_stuff_with({ red: 255, green: 127, blue: 63 });
この RFC は、名前付き引数を模倣しているが省略可能な引数は模倣していないと指摘している。それは主な動機ではない。主な動機は、タプルの手軽さと構造体の読みやすさを両取りすることだ。
この RFC が通ったとしよう。すると「Hello, world」の例の構文はどうなる? デフォルトのフィールド値も一緒に取り込まれ、構造化レコードと組み合わせて使えると仮定しよう。
fn example(
required: String,
{requiredNamed, namedOptional}: {
requiredNamed: Option,
namedOptional: String = "world".into()
}
): String {
format!("{required} {requiredNamed} {namedOptional}")
}
example("Hello", { requiredNamed: "beautiful", .. });
見覚えがある。実際これは TypeScript が使っている構文で、問題も山ほど引き継いでいる。引数名を二度書かされるのだ。
構造化レコードには他にも厄介ごとが多い:
- フィールドが1つだけのレコードはどうする?
{ x }はxを返すブロック式とも、構造化レコード式{ x: x, }とも読める。曖昧さを消すために、RFC はタプルと同じようにカンマを1つ足すことを提案している:{ x, }。だがこれでは Rust のパーサーがはるかに複雑になり、複数トークンの先読みが必要になる。 - これらの型に trait を実装できない。RFC は魔法のようにいくつかの trait を自動実装することを提案している。しかもタプルと違い、フィールド名のバリエーションが多すぎて、汎用 trait の実装をいくつか定義するわけにもいかない。
- 言語に不慣れな人がこの構文を Python の辞書や Dart の map 型と勘違いするかもしれない。
面白いことに、TypeScript には名前付きタプルフィールドがある。ただし型宣言の中にしか存在せず、フィールド名でフィールドにアクセスすることはできない。
type NewLocation = [lat: number, long: number]
const newLocations: NewLocation[] = [
[52.3702, 4.8952],
[53.3498, -6.2603]
]
const firstLat = newLocations[0][0]
const firstLong = newLocations[0][1]
Rust 言語チームは、こうした機能は有用に見えるものの実装が非常に難しく、その労力に見合わないと判断した。
C# の丸写し
構造体の省略可能フィールドの構文で合意が取れたのだから、それをそのまま関数に持ってこないか? まず、引数はすべて名前付きであれば任意の順序で指定でき、名前付き引数の後に順序が乱れた位置引数を続けられないようにする。
print_order_details(order_num: 31, product_name: "Red Mug", seller_name: "Gift Shop");
print_order_details(seller_name: "Gift Shop", product_name: "Red Mug", order_num: 31);
print_order_details("Gift Shop", 31, product_name: "Red Mug");
print_order_details(seller_name: "Gift Shop", 31, product_name: "Red Mug");
// this would cause an error
print_order_details(product_name: "Red Mug", 31, "Gift Shop");
次に、デフォルト値を定義し、デフォルト値を持つ引数を省略できるようにする。
fn print_order_details(product_name: &str, order_num: 31, seller_name: &str = "Gift Shop");
print_order_details("Red Mug", 31);
デフォルトフィールド値の提案と違い、ここでは末尾に .. を置けない。それ自体がれっきとした式だからだ。
このアイデアの最大の問題は、引数名が関数の公開 API の一部になってしまうことだ。関数側がそう望んだわけでもないのに。以前は、提案中の type ascription 機能と衝突するという反対意見があった。だがその RFC は後に削除されたので、もう心配しなくていいと思う。
公開討論
私の知る限り、これについて正式な RFC は一度も出ていない。だが数年前に Rust Internals Forum で pre-RFC が投稿されていて、これが私の知る中で最も詳細な名前付き引数の提案だ。ここでは RFC 全体を写すつもりはなく、高レベルな概要だけ述べる。
以下は RFC にある名前付き引数付き関数の例だ:
pub struct Database;
pub struct RegistrationError;
pub fn register(
pub name: String,
pub surname: String,
to db: Database
) -> Result {
/* ... */
}
register(name: "Alexis".into(), surname: "Poliorcetics".into(), to: my_db);
to db の例では、引数名は to だが、関数本体では db と呼ばれる。残り2つの引数は pub キーワードで示す。関数呼び出し時の引数名と関数本体内の引数名が同じになる。
RFC にはもっとずっと多くの詳細があり、関数オーバーロード、Fn trait での名前付き引数の使用、パターンからの名前の公開、メソッドオーバーロードが提案され、ドキュメント面の考慮も説明されている。
以下はコメントで見た批判だ:
- 順序なしの名前付き引数は除外されている。RustyFrog が挙げた理由は気に入らないが、このモデルで対応するのは難しくなさそうだ。
- デフォルト引数値も提案されていない。それこそが名前付き引数を欲しがる主な理由なのに。
- pre-RFC はデフォルト引数値を提案せず、オーバーロードを提案している。オーバーロードは Rust では極めて実装が難しく、省略可能引数ほど有用でもない。型ベースのオーバーロードではないので多少ましだが、それだけだ。
- この RFC は分量が多く、そのせいで承認されにくいかもしれない。
fn foo(name (a, b): name)など、構文の曖昧さがある。これは現状でも合法な構文だ。衝突する型注釈の提案もあるが、先に述べたとおりその RFC は後に撤回されている。pubの使用は、現在の使われ方を考えると混乱を招くかもしれない。個人的にはあまり気にしない。引数名が関数の公開 API の一部になるという効果があるからだ。とはいえ確かに少し奇妙だとは思う。Fntrait を変更する提案は未熟に見える。Fn(f32, f32) -> Stringtrait はFnに脱糖されるが、名前付き引数の脱糖がどうなるべきかは明らかでない。Fn実装の使い方は説明されているのに、fn型には全く触れていない。- pre-RFC は、名前付き引数を名前で使わなければならないのか、位置が正しければ技術的には十分なのかを明確にしていない。
- 名前付き引数を持つ extern 関数の扱いはかなり複雑で、おそらく対応しないほうがましなほどだ。
私の知る限り、著者はその後別の用事で忙しくなり、これらの問題に応答していない。だがこの pre-RFC は良い出発点だと思う。
構造体スタイルの関数呼び出し
このアイデアは2016年に nixpulvis が最初に提案した。他でも何度か見かけた。
// 1. Tuple style method call (current functions).
fn foo(a: u32) {}
foo(2);
// 2. Struct style method call.
fn foo { a: u32 }
foo { a: 2 };
// SomeType { a: 1 } -> SomeType
// SomeFunc { a: 1 } -> Codomain
// generally_lowercase { a: 1 } -> Codomain
// 3. Together.
fn foo(a: u32) { a: u32 } {}
foo(1) { a: 2 };
個人的には好きではない。構造体の構築と関数呼び出しが似すぎるからだ。コンパイラがどう解析するのかもわからない。とはいえ、ほとんどの言語がコンストラクタに new Foo() 構文を使うのだから、好む人もいるだろう。
Dart の丸写し
他に提案された案として、私自身も挙げたことがあるが、Dart の構文をそのまま持ってくるというものがある。こんな具合だ:
fn foo({ foo: &str = "Hello", bar: &str = "World" }) -> String {
format!("{foo} {bar}")
}
私には既存のパターンマッチ構文に似すぎているように見える。ただ、引数ごとにキーワードを足さなくて済むのは確かに良い。
edition による区別
後方互換性の問題を edition で解決しようという提案がある。Rust の将来の edition で書かれた関数に限り、名前付き引数を許可するというものだ。私に言わせれば、この破壊的変更は大きすぎて検討に値しない。
ドット接頭辞
2020 年に、名前付き変数と名前なし変数を区別するためにドット接頭辞を使うという RFC が立てられた。これはマージされなかった。名前付き引数を将来的にどう発展させるのかを示さない、あまりに漸進的な一歩にすぎなかったからだ。以下はその PR にあった例だ。
fn split(string: &str, .at: char, .limit: usize, .case_sensitive: bool) {}
split("hello world", .at = ' ', .limit = 2, .case_sensitive = true);
ドットが選ばれたのは 1 文字で済むからだ(pub キーワードにスペースを足すと 4 文字になる)。だが正直なところ、直感的とは言いがたい。ドットは構造体のフィールドも連想させるが、それはまったく無関係な概念だ。
私の提案
これらの提案を読んだうえで、完全な名前付き引数の提案に必要な部品はすでに揃っており、しかも上述の欠点はすべて避けられると思うに至った。いずれにせよこの概念に反対する人は必ず出るだろうが、本記事では名前付き引数への反論にできるかぎり答えた。残りの部分は pre-RFC であり、できるだけ多くの人を満足させるものだと私は考えている。どこに記事を投稿しても、そこでのコメントを追い、重大な批判がないか確認するつもりだ。正式に RFC を提出すると約束はできないが、もし提出しなければ、誰でも RFC を作成してよい。名前付き引数が真剣に提案されたのはずいぶん前のことだ。新しい提案を出すのに良い時期だと思う。
概要
関数に名前付き引数と省略可能引数を追加する。関数は位置引数と名前付き引数の両方を持てる。関数呼び出しでは名前付き引数を名前で参照でき、可読性と保守性が向上する。名前付き引数にはデフォルト値も持たせられ、呼び出し時に指定しなくて済む。
動機
安全性と可読性の向上
どの引数がどの位置にあるか覚え間違えることは、Rust を含む多くの言語でバグの主要な原因のひとつだ。標準ライブラリの関数を例に取ろう:[hard_link](https://doc.rust-lang.org/stable/std/fs/fn.hard_link.html)。
pub fn hard_link
, Q: AsRef>(
original: P,
link: Q,
) -> Result
2 つの引数は同じ型を受け取るので、ドキュメントを見なければどちらがどちらか分からない。一度間違えれば、実行時エラーやバグにつながる。
名前付き引数があれば、呼び出し側を見ただけで何が起きているか分かる。生成されるコードも読みやすくなる。
hard_link(original: "a.txt", link: "b.txt")?;
引数の多い関数では効果がさらに大きい。
// Without named arguments: what are these numbers?
solar_elevation(1787517098.0, 38.897957, -77.036560)
// With named arguments, it's obvious
solar_elevation(timestamp: 1787517098.0, latitude: 38.897957, longitude: -77.036560);
今の Rust でも似たことはできる。struct を新しく作り、それを関数の唯一の引数にするのだ。しかしあまりに冗長なので、実際に採用するライブラリはほとんどない。ましてや標準ライブラリはこのパターンをほぼ使わない。
ボイラープレートの削減
多くの関数には妥当なデフォルト値があり、本来なら渡すべき多数の引数も、位置引数がすべて必須である以上、すべて指定しなければならない。どの引数にも None を渡すのは可読性の問題を招く。
repository.checkout_index(None, None)?;
struct を引数として渡せば名前は手に入るが、省略可能な値にも None を指定する必要は残る。コンストラクタの末尾に ..Default::default() を足せばボイラープレートを減らせることもあるが、省略可能でないフィールドがひとつでもあれば Default は実現できない。Default を derive マクロで直接実装できなければ、手で実装するのも大量のボイラープレートだ。こうした問題の一部は RFC 3681 で解決できるが、それでも関数ひとつのために新しい struct を定義する必要がある。
let instance = Instance::new(InstanceDescriptor {
backends: Backends::default(),
flags: InstanceFlags::default(),
memory_budget_thresholds: MemoryBudgetThresholds::default(),
backend_options: BackendOptions::default(),
display: None,
});
こうした関数を呼ぶときのボイラープレートを減らすため、多くのライブラリが builder 型を実装し、すべての値を指定しなくて済むようにしている。代償は builder を作るためのボイラープレートであり、それがライブラリの公開 API の一部にもなる。bon のような crate を使えば struct の builder を宣言するのは楽になるが、それでも関数ひとつに struct をひとつ宣言する必要がある。コンパイル時間が増えるため、ライブラリがこうした crate を常に使うとも限らない。Command struct は 60 行(さらにドキュメントが何行も)を使って、ようやくこう書けるようにした:
Command::new("printenv")
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.env_clear()
.envs(&filtered_env)
.spawn()
.expect("printenv failed to start");
名前付き引数があれば、次の関数定義は簡単に書ける:
spawn(
command: "printenv",
stdin: Stdio::null(),
stdout: Stdio::inherit(),
env_clear: true,
envs: &filtered_env
).expect("printenv failed to start");
Bon には関数で名前付き引数を模倣する道具もあるが、そうした関数を呼ぶのにもボイラープレートが要る。
// Example from the bon README
#[builder]
fn greet(name: &str, level: Option) -> String {
let level = level.unwrap_or(0);
format!("Hello {name}! Your level is {level}")
}
// This could be shortened to greet(name: "Bon", level: 24)
let greeting = greet()
.name("Bon")
.level(24)
.call();
assert_eq!(greeting, "Hello Bon! Your level is 24");
実際のところ、余分な複雑さのせいでこうしたパターンは公開 API でしか使われない。つまり private な関数には、可読性を上げるために使える価値のある回避策がない。
互換性を壊さない拡張
今は、既存の関数に引数をひとつ追加するだけで破壊的変更になる。struct に新しいフィールドを足したいなら、既存のファクトリは妥当なデフォルト値を実装しなければならず、新機能を有効にする新しい関数も作らなければならない。
その結果、機能は似ているが引数が違う関数を多く持つ API が生まれる。たとえば標準ライブラリの HashMap には次のファクトリがある。
impl HashMap {
pub fn new() -> HashMap;
pub fn with_capacity(capacity: usize) -> HashMap;
}
impl HashMap {
pub fn new_in(alloc: A) -> Self;
pub fn with_capacity_in(capacity: usize, alloc: A) -> Self;
}
impl HashMap {
pub const fn with_hasher(hash_builder: S) -> HashMap;
pub fn with_capacity_and_hasher(capacity: usize, hasher: S) -> HashMap;
}
impl HashMap {
pub fn with_hasher_in(hash_builder: S, alloc: A) -> Self;
pub fn with_capacity_and_hasher_in(capacity: usize, hasher: S, alloc: A) -> Self;
}
これらの関数はすべてドキュメントに載り、API を汚している。関数の数は引数の数に対して指数関数的に増える。ジェネリック引数の違いを無視すれば(考えられる解決策は「将来の可能性」の節で述べる)、関数ひとつだけにできると想像できる。
pub fn new(
pub capacity: usize = 0,
pub alloc: A = Global,
pub hasher: S = RandomState,
);
いつか fill 引数を足したくなったら、既存の関数に足せばよく、破壊的変更にもならず、HashMap のファクトリ数を倍にすることもない。
ガイドレベル説明
引数には名前を付けられるし、付けなくてもよい。デフォルトでは関数のすべての引数に名前がなく、名前で参照できない。引数名の前に pub キーワードを付けると、名前付き引数を作れる。
fn print_labeled_measurement(pub value: i32, pub unit_label: char) {
println!("The measurement is: {value}{unit_label}");
}
fn main() {
print_labeled_measurement(5, 'h');
}
一見すると、名前なし引数と違いがないように見える。だが一度名前を付ければ、関数を呼ぶときにその名前を使える。そうすれば、関数に渡した値がそれぞれ何なのか、ひと目で分かる。
print_labeled_measurement(value: 5, unit_label: 'h');
名前付き引数にしたあとは、順序を入れ替えられる。どの引数がどこにあるかを覚えておく必要はない。
print_labeled_measurement(unit_label: 'h', value: 5);
順序には制約がある。ひとつの関数が名前付き引数と名前なし引数を同時に持つことはできる。ただしその場合、関数宣言でも関数本体でも、名前付き実引数はすべて位置実引数のあとに書かなければならない。
// unit_label is named, value is not
fn print_labeled_measurement(value: i32, pub unit_label: char) {
println!("The measurement is: {value}{unit_label}");
}
// forbidden: all named parameters must be after all unnamed parameters
fn _print_labeled_measurement(pub value: i32, unit_label: char) {
// ...
}
fn main() {
// forbidden: the parameter that 'h' is being passed into must be named
print_labeled_measurement(value: 5, 'h');
// forbidden
print_labeled_measurement(unit_label: 'h', 5);
}
名前付き引数にはデフォルト値も持たせられる。つまり、呼び出し側がその実引数の値を指定しなければ、自動的にデフォルト値が使われる。
fn print_labeled_measurement(pub value: i32, pub unit_label: char = 'm') {
println!("The measurement is: {value}{unit_label}");
}
fn main() {
// unit_label is unspecified and is defaulted to 'm'
print_labeled_measurement(5)
}
引数に名前がなくパターンとして定義されている場合、名前付き引数は使えない。たとえば、次のようには書けない。
fn print_coordinates(pub &(x, y): &(i32, i32)) {
// ...
}
ただし @ で束縛すれば、その引数に名前を付けられる。
fn print_coordinates(pub point @ &(x, y): &(i32, i32)) {
// ...
}
trait でも名前付き引数を使える。その場合、実装側は同じ名前を使わなければならない。ただし、trait の関数の引数にデフォルト値は持たせられない。
trait Trait {
fn f(pub a: i32, pub b: i32);
}
impl Trait for () {
// This is okay, because the parameter names match
// unused parameter warnings can be ignored using @
fn f(pub a: i32, pub b @ _: i32) {}
}
impl Trait for i32 {
// invalid: the second argument is named `b` in the definition
fn f(pub a: i32, pub c: i32) {}
}
impl Trait for bool {
// invalid: `b` is not public
fn f(pub a: i32, b: i32) {}
}
impl Trait for X {
// invalid: trait methods cannot have default parameter values
fn f(pub a: i32, pub b: i32 = 0) {}
}
引数名は任意なので、名前付き引数を持つ関数からも関数ポインタは作れる。ただし、関数ポインタからは引数名にアクセスできず、デフォルト値も使えない。Fn* 系の trait も同じだ。
fn foo(pub a: i32, pub b: i32) {}
let f: fn(i32, i32) = foo;
f(4, 2); // ok
f(a: 4, b: 2); // ERROR! `f` can't be called with named arguments
fn higher_order(f: Fn(i32, i32)) {
f(4, 2); // ok
f(a: 4, b: 2); // ERROR! `f` can't be called with named arguments
}
higher_order(foo); // ok
higher_order(|_, _| {}); // ok
参考レベルの説明
文法
関数宣言で公開引数をサポートするために変えるのは、関数の引数だけだ。まず pub キーワードを追加する。このあとには識別子パターンが続かなければならない。このパターンはデフォルト値も持てる。
FunctionParamPattern = PatternNoTopAlt ":" ( Type | "..." )
| "pub" IdentifierPattern ":" ( Type | "..." ) ( "=" Expr )?
関数呼び出しの文法はもう少し解析が複雑になるが、影響は CallParams だけにとどまる。
CallParam = Expression ( ":" Expression )?
CallParams = CallParam ( "," CallParam )* ","?
複雑になるのは、識別子がそのまま有効な式でもあるからだ。解決策は、まず式全体を解析し、そのあとにコロンが続くかどうかを見るというもの。引数名が識別子でなければ、コンパイラが後からエラーにできる。
静的意味論
関数の引数
デフォルト値が指定された FunctionParamPattern が与えられたとき、つまり
FunctionParamPattern = "pub" pat:IdentifierPattern ":" ( ty:Type | "..." ) ( "=" expr:Expr )
フィールドのデフォルト値と同様の規則が適用される。具体的には、
- 関数が
constなら、式exprは定数式でなければならない。 - 式
exprは型tyに強制変換できなければならない - 現在のエントリのジェネリック引数にアクセスできる。
fn foo(bar: usize = A) {}
- デフォルトの定数式は定義時には評価されず、インスタンス化時にのみ評価される。つまり、次のコードはコンパイルエラーにならない。
fn foo(a: usize = panic!(), b: usize = 42) {}
foo(a: 0);
- 関数のジェネリック引数は正しく伝播される。つまり、次のように書ける。
fn foo(bar: Vec = Vec::new()) {}
foo::();
#[allow(lint_name)]のような lint 属性をFunctionParamに付けると、式exprにも同様に作用する。- 未使用の引数名が
@で別のパターンに束縛されている場合、警告は出ない。束縛先のパターンからは依然として警告が出る可能性がある
公開引数については、一般にすべての公開引数がすべての非公開引数より厳密に後ろに来なければならない。
関数呼び出し
関数呼び出しの意味論は大きく変わる。一般に、すべての名前付き引数はすべての位置引数より後ろに来なければならない。位置引数については既存の意味論がそのまま当てはまる。
次の名前付き引数の生成規則に対して
CallParam = name:Expression ":" value:Expression
次の規則が適用される
- 式
nameは通常の識別子でなければならない。 - 識別子
nameはその関数のいずれかの公開引数の名前でなければならない。 - 式
valueは上記の公開引数の型に強制変換できなければならない。
Trait 宣言
trait 宣言には、関数宣言のすべての規則がそのまま当てはまる。さらに、
- trait メソッドはデフォルト引数の値を持てない。
trait 実装
trait 実装の意味論は trait 宣言のそれとは少し異なる。
- trait 宣言で public として宣言された引数は、trait 実装でも public でなければならない。
- public 引数の名前は宣言内の名前と一致しなければならない。
欠点
複雑さが増す
この機能は言語を少し複雑にする。とはいえ、数ある名前付き引数の提案のなかでは、本提案はかなり控えめだ。名前付き引数の文法は構造体フィールドの文法に似ており、デフォルト値の文法もフィールドのデフォルト値として提案されたものに近い。
名前付き引数の提案でよく挙がる懸念は、新しい関数にしか効かないため、どのライブラリが対応しているか覚えにくいというものだ。本提案は標準ライブラリに遡って適用できるので、言語をより一貫して使えるようになる。
名前付き引数は強制されない
名前が任意である点は、安全性の面での妥協と見ることもできる。手間を省きたいあまり名前を書かない人もいて、そうなると安全性の利点はまったく得られない。
だが、強制しないからこそ、より多くのライブラリ作者が破壊的変更を恐れずに使える。名前付き引数が関数呼び出しを冗長にしないと確信できれば、進んで対応する人も増える。
この欠点は実際に潜在的なバグを招く。リファクタリングで名前を消すと、そのあと引数が誤った位置に入り、バグにつながるおそれがある。
// before refactor
coordinates(y: 45.6, x: 6784.0);
// after refactor: incorrect order
coordinates(y, x);
緩和策:clippy lint を追加する
clippy lint で、可能な場合は名前付き引数を使うよう強制できる。
緩和策:将来の edition で名前付き引数を必須にする
大きな破壊的変更になるかもしれないが、必要と判断されれば、将来の edition で名前付き引数が必須になる可能性は十分ある。
型注釈を制限する
名前付き引数に関する初期の提案の多くは、ここで選んだ文法name: valueが当時提案されていた型注釈(type ascription)機能と衝突すると指摘していた。しかしその後、型注釈の RFC は撤回され、別の型注釈 RFC も却下された。理由のひとつは、名前付き引数と衝突しうることだった。だから、これはおそらくもう問題ではない。
根拠と代替案
なぜ名前付き引数を明示的なオプトインにするのか
Kotlin や C# など多くの言語では、任意の引数を名前付きで渡せる。しかし Rust では現実的ではない。現状、引数名の変更は破壊的変更とみなされていないが、名前付き引数を導入した途端に破壊的変更になる。解決策は、引数名が実装の詳細かどうかを著者自身に決めさせることだ。
pubキーワード
意味論的に見ると、pubキーワードを使うのは少し奇妙に思える。だがその効果は本質的に引数名を公開することであり、筋は通っている。本 RFC は他の可視性修飾子を提案しない。公開関数が内部的には名前付き引数を許可し、外部には許可しない、という状況が想像しにくいからだ。
他の修飾子も提案されている。名前付き引数を定義するためにextキーワードを追加する案が考えられる。以前の RFC ではドット記法が提案されたが、フィールドアクセスに似すぎていると判断された。@のような他の記号のほうが受け入れられやすいかもしれない。Swift の構文をそのまま持ち込み、識別子を2つ書くという案もある。
fn foo(bar bar: &str) {}
同じ引数名を2回書くのは冗長で、たいてい不要だ。変数を再束縛する構文はすでにある。
Dart は名前付き引数を波括弧で囲む。pubキーワードを繰り返し書かずに済むのが利点だ。しかし Rust の文脈に置くと、これは分解パターンに見え、意味論的には正しくない。
// In Dart
void enableFlags({bool? bold, bool? hidden})
enableFlags(bold: true)
// Similar idea, in Rust
fn enable_flags({ bold: Option = None, hidden: Option = None })
enable_flags(bold: Some(true))
名前付き引数を持つ関数呼び出しで、丸括弧ではなく波括弧を使うという提案もあった。構造体の構築に似て見える。だが非常に紛らわしく、両機能の境界も曖昧になるだろう。
fn foo (a: u32) { b: u32 } {}
foo(1) { b: 2 };
コロンと等号
コロン(:)の代わりに等号(=)を使うという提案がある。構文は代入に近づき、意味論的にもまさに代入であり、型注釈とも衝突しない。
本 RFC がコロン構文を選ぶのは、構造体初期化のスタイルと揃えるためだ。
引数の並べ替え
名前付き引数に関する提案のいくつかは、単純さのために引数の並べ替えを省いている。本 RFC は慎重を期して並べ替えを認める。もう一つの選択肢は引数を元の順序でしか書けなくすることだが、ユーザーが順序を変えたいときに不要な抵抗感が生まれる。
言語によっては本 RFC より柔軟な並べ替えを認めている。C# では、引数間の順序さえ保たれていれば、名前付き引数を名前なし引数より前に置ける。本 RFC はユーザーにとっての混乱を避けるため、この機能を採り入れていない。ここで提案する並べ替えは、将来より柔軟な並べ替えを実装する余地を残している。
trait ではデフォルト値をサポートしない
本 RFC は現時点で trait のデフォルト引数値を禁止している。trait 引数のデフォルト値を trait 定義側に置くべきか、trait 実装側に置くべきかが明確でないからだ。実装側がデフォルト値を定義できるなら、挙動はより予測しやすくなるだろう。しかしデフォルト値が trait の dyn 互換性をどう保てるのか想像しにくい。デフォルト値を trait 定義側に置けば、trait は dyn 互換のままでいられる。
const コンテキストについて
デフォルトフィールド値の RFC では、構造体のデフォルト値は const でなければならないと決められた。副作用を持つ関数がコンパイラとユーザーの両方を混乱させる恐れがあるからだ。しかしこの理由は関数には当てはまらない。非 const 関数そのものに「安価」や「一貫性がある」という期待は伴わないからだ。常にデフォルト値を const に要求することは可能だが、そうする強い理由は見当たらない。
ただし、const 関数内でデフォルト引数が const でなくてよいとすると混乱するので、その場合は const であることを要求する。
名前付き引数は必須ではない
前述の「欠点」を参照。
代替案:何もしない
「動機」で詳述したとおり、現状は冗長すぎて、構造体引数や builder といった既存の代替手段はそもそもほとんど使われていない。これらのパターンが存在すること自体、ユーザーが名前付き引数を何らかの形で模倣したいと考えている証拠だ。そしてこれらの代替手段がプライベート API で採用されないことが、現状が実際に腹立たしいほど冗長であることを示している。
代替案:構造体レコード
構造体レコードとフィールドのデフォルト値で名前付き引数を模倣する案は多く提案されている。TypeScript などの言語でよく使われる書き方だ。関数の宣言はおおよそ次のようになる。
fn foo({ a, b, c }: { a: f64, b: f64, c: f64 }) {}
このパターンにはいくつか批判がある。
- 既存の関数に破壊的変更なしで移植できないため、標準ライブラリでは使えない。
- この宣言に現れる識別子はすべて2回書くことになる。糖衣構文を導入すればよいかもしれないが、そうした構文はまだ提案されていない。
- 構造体レコードには以前 RFC の提案があったが、実装が複雑すぎるという理由で却下された。
構造体レコードが実現すれば、名前付き引数の暫定的な妥協案にはなる。だが実現するとは思えない。妥協案が本命の案より実装が難しいのなら、本命の案をそのまま使えばよい。
代替案:構造体名の推論
名前付き引数を使いやすくする提案の一つに、構造体を構築する際にその名前を推論できるようにするものがある。これなら構造体引数を取る関数の呼び出し側は、ほぼ簡潔に見える。
foo(_ { a: 1, b: "2", c: [] });
しかしこれも、関数が明示的に対応していること、つまり渡す構造体を定義していることを要求する。既存の関数に移植することもできない。現状に対する利点は呼び出し側で構造体名を書かずに済むことだけであり、それはこのパターンに対する主な批判ではない。プライベート関数でこの書き方が採用される可能性も依然として低い。
代替案:Fn trait を実装する
この提案は何度か見かけたことがあるが、持ち出す人の多くは冗談半分だ。Fn を実現するというのは、他の言語にあるメソッドのオーバーロードを、より冗長に書き直すことに等しい。オーバーロードは Rust で実装するのが難しく、議論も多い。だからこの案が採用される見込みはまずない。
既存の取り組み
この RFC の中でも、Rust や他の言語で名前付き引数を実現しようとした試みがいくつか挙げられている。
Rust には以前、名前付き引数に関する RFC があったが、却下された主な理由は、名前付き引数と省略可能引数を組み合わせたときの将来像が示されていなかったことだ。この RFC では、名前付き引数の将来の使われ方をできる限り網羅し、全体像を示すようにしている。
フォーラムユーザーの Azerupi が pre-RFC を出したが、それを引き継ぐ RFC は出なかったようだ。コメントでは複数の複雑な点が指摘された。この RFC は、構文の曖昧さ、型システム、オプトイン方式の名前付き引数という三つの問題に取り組む。
別の pre-RFC はより Swift 寄りの構文を提案していたが、この RFC はそれを踏襲していない。その pre-RFC はオーバーロードを支持する一方で、デフォルト引数は支持していなかった。後者のほうが前者より重要だと言える。
前述のとおり、bon は関数から builder を生成する手続きマクロを提供している。これが存在すること自体、名前付き引数への需要を示しており、言語機能として扱うほうが適切だ。
名前付き引数をサポートする他の言語も議論された。ここで提案する方式は C# や Kotlin の名前付き引数に似ているが、名前付き引数がオプトインである点が異なる。Dart と Swift の構文も検討された。Dart の構文は次のとおりで、名前付き引数というより分割代入に見える。
void enableFlags({bool? bold, bool? hidden})
enableFlags(bold: true)
Swift はデフォルトで全引数が名前付きで、変数名の前にアンダースコアを付けるとその挙動を外せる。Rust に持ち込むには言語に破壊的変更が必要になるため、現実的ではない。各種提案は少しずつ異なる構文を試してきたが、その多くが構文の曖昧さを招いた。
未解決の問題
- 名前付き引数は位置順に与えることを必須にすべきか?
pubが最良のキーワードか、それとも別のキーワードを使うべきか?- デフォルト値は常に const であることを必須にすべきか?
将来の可能性
trait メソッドのデフォルト引数値
trait メソッドは以前、デフォルト値を持てないとされていた。デフォルト値を trait の宣言に置くか、trait の実装に置くかで結論が出ていなかったからだ。実装に置くのは関連型や定数と同じ働き方だが、そうするとこうした trait を dyn 互換に保つのが難しくなる。
一つの解決策は、非 dyn の文脈でのみデフォルト値を使えるようにすることだ。
trait Foo {
fn bar(&self, baz: i32 = 0);
}
impl Foo for i32 {
fn bar(&self, baz: i32 = i32::MAX) {}
}
fn do_bar(x: &T) {
x.bar(); // baz doesn't need to be specified, since we're using generics
}
fn do_bar_2(x: &dyn T) {
x.bar(); // ERROR: baz must be specified when using a method on a dyn object
}
trait の拡張
理論上は、デフォルト引数値を与えさえすれば、次のような書き方を妨げるものは何もない。
impl Default for Vec {
fn default(capacity: usize = 0) -> Self {
Self::with_capacity(capacity)
}
}
ただし、default 関数の挙動が一貫しなくなるうえ、dyn の文脈ではさらに厄介だ。考えられる対処は Vec から dyn Default への変換を禁止することだが、そうすると名前付き引数の導入が破壊的変更になってしまう。
将来の edition で名前付き引数を必須にする
名前付き引数を既存の関数に破壊的変更なしで後から導入できるようにするには、省略可能にしておく必要がある。これにはいくつかの妥協が伴う。「欠点」の節で述べたとおりだ。とはいえ、この RFC は Rust の将来の edition で名前付き引数を必須にできるよう書かれている。名前付き引数を指定しても、旧 edition でも新 edition でも警告は出ない。
早すぎるタイミングでこれをやれば、名前付き引数の普及を損なう可能性が高い。既存の関数に後から導入するのが破壊的な API 変更になるからだ。名前付き引数を使う関数呼び出しも冗長になり、普及にさらに不利になる。
より柔軟な並べ替え
前述のとおり、この RFC は名前付き引数の並べ替えを限定的に認めている。この制限を残すのは、規則を説明しやすく、推論しやすくするためだ。ただし、引数の並べ替えをより柔軟に認めている言語もあり、将来はそうすることもできる。
C# の主な規則は、順序が入れ替わった名前付き引数の後ろに位置引数を置けないというものだ。
print_order_details(order_num: 31, product_name: "Red Mug", seller_name: "Gift Shop");
print_order_details(seller_name: "Gift Shop", product_name: "Red Mug", order_num: 31);
print_order_details("Gift Shop", 31, product_name: "Red Mug");
print_order_details(seller_name: "Gift Shop", 31, product_name: "Red Mug");
// this would cause an error
print_order_details(product_name: "Red Mug", 31, "Gift Shop");
デフォルト値が推論に影響する
2022 年、Gankra はデフォルト引数値をジェネリック型引数の推論に組み込むことを提案した。次のように書けるだろう。
fn default_hasher() -> RandomState { ... }
impl HashMap {
fn new(hasher: S = RandomState::default()) { ... }
}
// uses S=RandomState
let map = HashMap::new();
Gankra が指摘するように、この方法の問題は、S の型が RandomState である場合にのみその引数を省略できることだ。とはいえ、既存の HashMap ファクトリメソッドもまさにそうなっている。default を使う場合を除いては——こちらは引数を一切取らない。
ジェネリック const
Vec に関しては、この版の RFC では省略可能な capacity 引数を追加できない。Vec::new が const 関数であり、メモリの確保はコンパイル時に完了できないからだ。この問題は非常に難しく、永遠に解けないかもしれない。ただ、言語にキーワードジェネリクスが入れば、次のように書けるかもしれない。
const trait Capacity: Copy {
const fn allocate(self) -> Option]>>;
const fn as_usize(self) -> usize;
}
const impl Capacity for () {
const fn allocate(self) -> Option]>> {
None
}
const fn as_usize(self) -> usize {
0
}
}
impl Capacity for usize {
fn allocate(self) -> Option]>> {
Some(Box::default())
}
const fn as_usize(self) -> usize {
self
}
}
const impl Capacity> Vec {
pub const fn new(capacity: Cap = ()) -> Self {
Self {
len: 0,
capacity: capacity.as_usize(),
buffer: capacity.allocate(),
}
}
}
引数値を与えなければ、関数は const だ。だが usize を与えた時点で、const ではなくなる。