0️⃣

【Rust】Option<NonZero*>でコードを安全に読みやすく

2024/01/14に公開

はじめに

NonZoroU32やNonZoroI32等のNonZero系のオブジェクトの使い方について日本語で解説している記事があまり見当たらないのですが、実際に使ってみて多くのメリットを感じたので、記事にしてみたいと思います。

NonZero*?

Rust 1.28から使用できるようになった、値が0にならない整数を扱うための型です。
std::numで定義されている標準ライブラリの機能です。

NonZeroU8 NonZeroU16 NonZeroU32 NonZeroU64 NonZeroU128 NonZeroUsize
NonZeroI8 NonZeroI16 NonZeroI32 NonZeroI64 NonZeroI128 NonZeroIsize

と、整数型毎に用意されています。

これを使用するメリット

0以外の値が期待される場合に、それを強制する or 関数の利用者に対して、引数に入る値が0以外である事をノーコメントで説明できる、というメリットがあります。

また、0以外の値を入れようとした場合に、それを検知するという役割も果たすため、プログラミングの早い段階でミスを検出できる、というメリットもあるように感じました。

実際のコード

use std::num::NonZeroU32;

fn main{
    let num0 = NonZeroU32::new(0);
    assert_eq!(num0, None);

    let num1 = NonZeroU32::new(1);
    if let Some(num1) = num1 {
        assert_eq!(num1.get(), 1);
    }
}

newメソッドに値を渡す事でOption<NonZero*>が返ってきます。
この時に、値が0の場合はNoneとなります。

0以外の場合は
Some(NonZero*)
となりますが、derefなどは実装されていないようで、getメソッドで値を取り出す必要があります。

NonZeroを使う場合と使わない場合の比較

単にローカル変数として使うだけの場合は特にメリットは感じられないかもしれませんが、関数の引数や、オブジェクトのメンバとして使うような場合は、コード自体が変数の意味を表すという事にメリットがあります。

メンバ変数nの値が0以外でなければならないと仮定し、両者を比較するとこのようになります。

NonZeroを使わない場合

struct A{
	n:u32
}
impl A{
	pub fn new(n:u32)->Self{
		if(n==0){
			panic!("nには0以外を指定しなければなりません");
		}
		Self{n}
	}
}

NonZeroを使う場合

struct B{
	n:NonZeroU32
}
impl B{
	pub fn new(n:NonZeroU32)->Self{
		Self{n}
	}
}

struct Aでは、newメソッドで0を渡す事が可能であるため、0を渡された場合に何らかのエラー処理が必要となります。
それに対して、struct AではnewメソッドでNonZeroU32しか受け付けないため、そもそも0を受け取る事がありません。
そのため、オブジェクトの作成元が責任を持って0以外の値を渡す必要があるわけです。

しかも、struct Aの場合はnewに0を渡してはいけない、という事が実際に実行するまでわかりません。
struct Bの場合は、引数の型を見るだけでそれが判別できます。

このことは、プログラムの読みやすさに関わってきますし、より安全なコードの記述が半ば強制されるため、生産性にも大きく関わってくると思います。

仮にstruct Aのnewでpanicさせずに、0を受け付けてオブジェクトを生成できたとします。
もしメンバ変数に0が入っているとロジック的に破綻するような処理がどこかにあれば、これは最も最悪なパターンで、プログラマの意図しないところでバグが発生し、場合によっては発生源を特定するのがとても困難になる可能性があります。

Option<NonZero*>

値として0は許容したいけど0の場合に特別な分岐処理が発生するような場合、やはりNonZero*を使う方が良いと私は考えます。

その場合は、見出しにすでに書いていますが、
Option<NonZero*>
を使う事で実現する事ができます。

struct B{
	n:Option<NonZeroU32>
}
impl B{
	pub fn new(n:Option<NonZeroU32>)->Self{
		Self{n}
	}
}

このようにすると、0以外を強制することなくNonZero*のメリットを享受する事ができます。

この場合のメリットとは

・nは0以外の値である事が暗黙的に説明される
・structのimpl内でnを扱う場合に、NonZeroである事を常に強制的に意識させられる → 誤ったロジックを書いてしまう可能性が下がり、より安全なプログラミングができるかもしれない。

といった事が考えられます。

そして、一般的にOptionで包むとメモリレイアウト的にSome or Noneを識別するための情報が必要となり、容量が大きくなります。
しかし、NonZero*やNonNullのような非ゼロになる事が決まっている型に対しては、値の0とNoneが同一視され、それ以外の値はSomeとして扱われ、識別のための追加の情報が不要となり、容量が変わりません。
ですので、容量制限がシビアな状況であっても遠慮なく使う事ができます。
また、処理速度の面においても、これを使って不利になるような事は経験上ありませんでした。

Option<NonZero*>を使う場合の分岐処理

もう一つ例を挙げます。

NonZeroを使わない場合

struct A{
	n:u32
}
impl A{
	pub fn hoge(&self){
		if self.n!=0{
			//何らかの処理
		}
	}
}

Option<NonZero*>を使う場合

struct B{
	n:Option<NonZeroU32>
}
impl B{
	pub fn hoge(&self){
		if let Some(n)=self.n{
			//何らかの処理
		}
	}
}

これらは処理内容的には全く同じです。

しかし、NonZeroを使わない場合、self.nが0かどうかをチェックするのは、プログラマが意識的に書いてやる必要があります。このような記述は、ついうっかり忘れてしまう事があります

それに対し、Option<NonZero*>を使う場合は、直接的に値を取り出すことができないため、

let Some(n)=self.n

self.n.unwrap()

を強制されます。

そのため、ロジック的な破綻を未然に防いだり、早期に発見出来たりすることにつながると思います。

まとめ

NonZero*を使う事のメリットを、できるだけ分かりやすくなるように心がけて書いてみました。

使いどころは局所的で限られているかもしれませんが、上手く使えれば読みやすく安全なコードに近づく事ができるものだと思います。

NonZero*を使う事のメリットは他にもあるとは思いますが、今回書いた内容だけでも使う価値があると感じていただければ嬉しいです。

Discussion