⚙️

Rust の generic_const_exprs の紹介

2023/02/10に公開

はじめに

こんにちは!
入社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 型の定数に対応した型 UsizeBool をそれぞれ作っておきます。
次に 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 }>: IsTruewhere 節に与えることで 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()
    }
}

ためしに奇数要素を持つ ArrayWrapperprintln![3]に適用してみると、以下の画像のようにコンパイルエラーが生成されます。

奇数要素を持つ  に対してコンパイルエラーが生成されている図

(画像:奇数要素を持つ ArrayWrapper に対してコンパイルエラーが生成されている図)

マクロ経由で fmt メソッドが呼び出されていることでコンパイルエラーが不親切になっていますが、直接呼び出せば以下のような比較的わかりやすいエラーが生成されます。

比較的わかりやすい型エラー

(画像:比較的わかりやすい型エラー)

Usize<11>: IsEven が満たされていない、すなわち N が偶数ではないということが明確に示されており、今回のケースではどのように修正を加えればよいか一目瞭然です。

コンパイル時計算の結果を型の制約として利用できるという特徴は使い所がありそうですし、将来的に const generics の型として &str やユーザー定義型を埋め込めるようになれば、型レベルで多種多様な計算ができるようになるかもしれません。

さいごに

プログラミング言語 Rust の unstable features の1つである generic_const_exprs について触れ、前提となる const generics とともにその機能について述べていきました。

普段使いするプログラミング言語なら、アップデートのたびに Release notes を読みに行き、新しく何ができるようになったかを知る機会は少なくないでしょう。しかし、今はできないが、できるようになりつつあるような言語機能についても調べてみることで、そのプログラミング言語をより深く学ぶことができ、愛もより強いものになるのではないでしょうか。
みなさんも「推し言語」があれば、それに将来導入されるかもしれない機能を探してみると楽しいかと思います。

脚注
  1. ざっくり言えば、stable にリリースされる予定のものがテストされる channel です。リリースに先立って新しい機能に触れることができます。そこでバグを見つけたら、開発チームにフィードバックを送ると良いと思います。 ↩︎

  2. 「関数を型に適用している」というよりは、「関数を定数に適用していて、それが型にもなる」というイメージをするとしっくりすると思います。 ↩︎

  3. :? という出力形式は、内部的に std::fmt::Debug trait の実装をフォーマット対象に要求します。詳細はstd::fmtを参照。 ↩︎

FORCIA Tech Blog

Discussion