Rust の generic_const_exprs の紹介
はじめに
こんにちは!
入社1年目、旅行プラットフォーム部エンジニアの辻󠄀です。
突然ですが、みなさんは好きなプログラミング言語をお持ちでしょうか?
私は Rust というプログラミング言語が好きで、個人開発や研究、業務、インターンなど、多くの機会で利用してきました。
プログラミング言語を学んで長いこと使っていると、実装するものだけでなくその言語自体に愛着が湧き、より深く知りたくなっていきます。
例えば、エンドユーザーである我々が普段利用したことのない開発中の言語機能や、言語を取り巻くエコシステムの今後の改善について興味が湧くこともあるでしょう。
この記事では、私がそのような興味のもと調査した Rust の unstable features の1つである generic_const_exprs について、前提知識とともに紹介していきます。
※この記事では、以下の環境で動作確認をしています。
$ rustc --version
rustc 1.69.0-nightly (5e37043d6 2023-01-22)
nightly channel と unstable features
Rust には、以下の3つの release channel が存在しています。
- stable
- beta[1]
- nightly
普段我々が利用しているのが stable channel(いわゆる安定版)で、2023/02/01現在ではバージョン1.67.0を迎えています。
Rust では stable channel のバージョンアップのたびに新しく追加された機能がブログ上で告知されているのですが、私は毎回ワクワクしながら覗きに行っています。
一方で nightly channel では、今後 stable channel に導入されるかもしれない実験的な機能が多く存在しています。
これらの機能は unstable features と呼ばれ、日々 GitHub の issue 上で仕様検討や実装、テストが進められています。
この記事で紹介する generic_const_exprs は unstable features の1つであり、既に stable channel に導入されている言語機能である const generics を拡張するものであるため、まずそちらから軽く紹介していこうと思います。
const generics
Rust は generics (parametric polymorphism) をサポートしており、異なる型に対して共通した振る舞いを記述できます。
const generics はバージョン1.51.0にて導入された言語機能で、generics の型パラメータにコンパイル時定数を埋め込めるようになりました。
ここでは例として、固定長配列をラップした型 ArrayWrapper
と、std::fmt::Debug
trait の実装を考えてみます。
固定長配列は型T
と配列の長さから構成されるため、長さ4の配列に対する ArrayWrapper
の定義と Debug
trait の実装は以下のようになるでしょう。
use std::fmt;
struct Array4Wrapper<T>([T; 4]);
impl<T: fmt::Debug> fmt::Debug for Array4Wrapper<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_list().entries(self.0.iter()).finish()
}
}
ここで、別の長さを持つ固定長配列に対する Wrapper を作成したい場合、別途型を定義して実装も行う必要があります。
use std::fmt;
struct Array7Wrapper<T>([T; 7]);
impl<T: fmt::Debug> fmt::Debug for Array7Wrapper<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_list().entries(self.0.iter()).finish()
}
}
このままでは、異なる長さの固定長配列の Wrapper が必要になるたびに、ほぼ同じ型定義と実装を書かなければなりません。
これでは記述量が多く変更にも弱くなってしまうため、コードの保守が難しくなってしまいます。
従来はマクロを用いてコードを複製することでこの手の課題をある程度解決していたのですが、const generics の導入後は以下のように簡潔かつ網羅的に実装できるようになりました。
use std::fmt;
struct ArrayWrapper<T, const N: usize>([T; N]);
impl<T: fmt::Debug, const N: usize> fmt::Debug for ArrayWrapper<T, N> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_list().entries(self.0.iter()).finish()
}
}
ArrayWrapper
の型パラメータに const N: usize
が追加されており、N
が任意の定数であることが明示されています。
そして、ここで定義した N
を用いて配列の型を [T; N]
とすることで、任意要素数の固定長配列をラップできるようになっています。
これで何度も似たコードを書くことなく、配列の長さによらない実装を記述できるようになりました。
これだけでも非常に便利な const generics ですが、現在は型パラメータに定数や定数同士の簡単な計算しか盛り込むことができません。
generic_const_expr はこの機能を拡張しており、型パラメータを用いた複雑なコンパイル時定数の埋め込みができるようになっています。
準備
unstable features を利用するには、nightly channel の Rust コンパイラが必要になります。
インストール方法と設定方法はTRPLに書いてありますが、概略は以下のとおりです。
# nightly channel のコンパイラをインストール
$ rustup toolchain install nightly
# デフォルトを nightly に変更
$ rustup default nightly
# あるいは、特定の project のみ nightly に変更
$ cd /path/to/project
$ rustup override set nightly
また、特定のファイルで generic_const_exprs を使えるようにするためには、その先頭に以下の inner attribute を記述しておく必要があります。
#![feature(generic_const_exprs)]
generic_const_exprs
generic_const_exprs では、定数だけでなく const generics の型パラメータを用いた計算が行なえます。
例えば、コンパイル時計算を提供する const fn
に対して型パラメータ N
を適用できます[2]。
以下は、フィボナッチ数列の第n項の数だけ要素を持つような固定長配列に対する type alias である FibArray
の定義です。
const fn fib(n: usize) -> usize {
let mut base = 0;
let mut next = 1;
let mut cnt = 0;
while cnt < n {
let tmp = next;
next += base;
base = tmp;
cnt += 1;
}
base
}
type FibArray<T, const N: usize> = [T; fib(N)];
const generics 単体では [T; fib(10)]
といった定数による計算しか行えなかったのに対し、
generic_const_exprs を利用することで型パラメータ N
を用いた [T; fib(N)]
という型定義が可能になっています。
この型をいざ利用してみると、以下の画像のようになります。
(画像: FibArray<usize, 10>
と [0; fib(20)]
が要素数不一致で型エラーを起こしている図)
型 FibArray<usize, 10>
は要素数fib(10)
(=55)のusize
型の配列を期待しているのに対し、与えられた配列は要素数がfib(20)
(=6765)であるため、正しくコンパイルエラーが出力されています。
ただコンパイル時計算の結果を型パラメータに適用できるだけでは使い道が少ないように思えますが、
この機能を悪用することで、以下のように数値に制約をかけることができるようにもなります。
// 定数情報を持つ型の定義
struct Usize<const N: usize>;
struct Bool<const B: bool>;
// true に対する制約の定義
trait IsTrue {}
impl IsTrue for Bool<true> {}
// 偶数に対する制約の定義
trait IsEven {}
impl<const N: usize> IsEven for Usize<N> where Bool<{ N % 2 == 0 }>: IsTrue {}
まず制約として trait を作成したいのですが、trait は定数に対して直接実装できないため、ここでは usize
型と bool
型の定数に対応した型 Usize
と Bool
をそれぞれ作っておきます。
次に IsTrue
trait を定義し、Bool<true>
にのみ実装します。これにより、あるコンパイル時計算 e
の結果が true
であるということを Bool<{e}>: IsTrue
という制約で表現できるようになります。
最後に、定数が偶数であることを表現する IsEven
trait を定義します。
IsTrue
trait を Bool
型に対して実装したのと同様に IsEven
trait を Usize
型に対して実装しますが、Usize
型の型パラメータ N
は偶数である必要があります。
そこで IsTrue
の出番です。
あるusize
型の定数 N
が偶数であるという制約 Bool<{ N % 2 == 0 }>: IsTrue
を where
節に与えることで IsEven
を適切に実装できそうです。
結果として、IsTrue
という制約から IsEven
という別の制約を作ることができました。
この IsEven
trait を用いると、先ほど定義した ArrayWrapper
に対して要素数が偶数である場合にのみ std::fmt::Debug
を実装するということが実現できます。
// 偶数個の要素を持つ ArrayWrapper に対して std::fmt::Debug を実装
impl<T: fmt::Debug, const N: usize> fmt::Debug for ArrayWrapper<T, N>
where
Usize<N>: IsEven,
{
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_list().entries(self.0.iter()).finish()
}
}
ためしに奇数要素を持つ ArrayWrapper
を println!
[3]に適用してみると、以下の画像のようにコンパイルエラーが生成されます。
(画像:奇数要素を持つ ArrayWrapper
に対してコンパイルエラーが生成されている図)
マクロ経由で fmt
メソッドが呼び出されていることでコンパイルエラーが不親切になっていますが、直接呼び出せば以下のような比較的わかりやすいエラーが生成されます。
(画像:比較的わかりやすい型エラー)
Usize<11>: IsEven
が満たされていない、すなわち N
が偶数ではないということが明確に示されており、今回のケースではどのように修正を加えればよいか一目瞭然です。
コンパイル時計算の結果を型の制約として利用できるという特徴は使い所がありそうですし、将来的に const generics の型として &str
やユーザー定義型を埋め込めるようになれば、型レベルで多種多様な計算ができるようになるかもしれません。
さいごに
プログラミング言語 Rust の unstable features の1つである generic_const_exprs について触れ、前提となる const generics とともにその機能について述べていきました。
普段使いするプログラミング言語なら、アップデートのたびに Release notes を読みに行き、新しく何ができるようになったかを知る機会は少なくないでしょう。しかし、今はできないが、できるようになりつつあるような言語機能についても調べてみることで、そのプログラミング言語をより深く学ぶことができ、愛もより強いものになるのではないでしょうか。
みなさんも「推し言語」があれば、それに将来導入されるかもしれない機能を探してみると楽しいかと思います。
Discussion