4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rust 1.96の新RangeでSpanをCopyにできる理由

4
Posted at

Rust 1.96の新RangeでSpanをCopyにできる理由

開始位置と終了位置しか持たない Span なら、Copy にできそうに見えます。ところが std::ops::Range<usize> をそのまま持たせると #[derive(Copy)] は通りません。

理由は Range のフィールドではなく、旧 Range が範囲を表す値であると同時にIteratorの状態でもあることにあります。Rust 1.96.0では、この2つの責務を分けた新しい core::range::Range が安定化されました。

この違いを最小コードで見ると、なぜ新しい Range が Copy になれるのか、公開APIでは何を型にすべきかがかなり明確になります。

小さなSpanなのにCopyをderiveできない

確認環境は Rust 1.98 系です。外部crateは使いません。

まず、従来の std::ops::Range をフィールドに持つ型を書きます。

use std::ops::Range;

#[derive(Clone, Copy)]
struct Span {
    range: Range<usize>,
}

fn main() {
    let span = Span { range: 2..5 };
    println!("{}..{}", span.range.start, span.range.end);
}

これはコンパイルできません。問題は usize ではありません。usize 自体は Copy です。

Range<usize> の中身も公開フィールドだけを見ると単純です。

pub struct Range<Idx> {
    pub start: Idx,
    pub end: Idx,
}

それでも旧 Range は Copy を実装していません。

ここで重要なのは、Rustの Copy が「メモリ上で複製できそうか」だけで決まる性質ではないことです。フィールド型自身が Copy を実装している必要があります。

では、なぜ標準ライブラリは旧 Range を Copy にしなかったのでしょうか。

RangeとIteratorを同じ値にしたことがCopyを難しくした

Rust 1.96の新RangeでSpanをCopyにできる理由

旧 Range は Iterator を直接実装しています。

普段から何気なく、こう書けます。

fn main() {
    let mut range = 2..5;

    println!("{:?}", range.next());
    println!("{}..{}", range.start, range.end);

    println!("{:?}", range.next());
    println!("{}..{}", range.start, range.end);
}

概念的には、状態は次のように変わります。

2..5
↓ next()
3..5
↓ next()
4..5

つまり start と end は単なる「元の範囲」の記録ではありません。next() を呼んだ後には、反復処理の残りを表す状態にもなります。

ここで Copy まで実装すると、範囲をコピーしたつもりなのか、途中まで消費したIteratorの状態をコピーしたのかが同じ型の操作として混ざります。

技術的に値を複製できるかどうかとは別に、Iterator と Copy を同じ型へ持たせるのは扱いを誤りやすい設計です。Rust 1.96.0のリリースノートでも、この点を理由として旧Rangeでは Copy が避けられていたと説明されています。

Rust 1.96.0で安定化された新しいRange系では、ここを変えています。

新しい core::range::Range は Iterator そのものではありません。代わりに IntoIterator を実装し、反復するときに別の RangeIter を作ります。

イメージとしては、

旧:
Range = 範囲 + 反復状態

新:
Range = 範囲
RangeIter = 反復状態

という分離です。

範囲を表す値自身を next() で書き換える必要がなくなったため、Idx: Copy なら Range<Idx> も Copy にできます。

これは単にトレイトが一つ増えた変更ではなく、値が表すものと処理途中の状態を別の型へ分離した変更と見ると理解しやすいです。

新しいcore::range::RangeならSpanをCopyにできる

Rust 1.96.0で安定化された型を使ってみます。

use core::range::Range;

#[derive(Clone, Copy, Debug)]
struct Span {
    range: Range<usize>,
}

fn main() {
    let a = Span {
        range: Range { start: 2, end: 5 },
    };

    let b = a;

    println!("{:?}", a);
    println!("{:?}", b);

    let values: Vec<_> = a.range.into_iter().collect();
    println!("{values:?}");

    println!("{:?}", a);
}

Span は Copy にできます。

さらに注目したいのは反復です。

let values: Vec<_> = a.range.into_iter().collect();

新しい Range では IntoIterator によって RangeIter が生成されます。反復処理の進行状態を持つのは、このIterator側です。

Range 自身は開始位置と終了位置を表す値として残せます。

なお、2026年10月時点の安定版ドキュメントでも、start..end という範囲構文が生成する型と新しいRange型の移行は完了していません。新しい std::range のドキュメントには、将来のeditionで範囲構文が新型を生成する予定であり、現時点ではそうなっていないことが明記されています。

そのため、次の2つを同じ型だと思ってAPIを書くと引っかかります。

let legacy = 2..5;

let new_range = core::range::Range {
    start: 2,
    end: 5,
};

見た目の意味は同じでも、現在は別の具体型です。

新しいRangeを構造体内部で使うだけなら問題は小さいのですが、ライブラリの公開APIへ具体型を出す場合は、この移行期間を意識したほうがよいです。

公開APIではRangeBoundsで境界を受ける

たとえば、文字列やバッファの一部分を扱う関数があるとします。

具体的な旧Rangeを引数にすると、APIがその型へ固定されます。

use std::ops::Range;

fn inspect(range: Range<usize>) {
    println!("{}..{}", range.start, range.end);
}

fn main() {
    inspect(2..5);
}

関数が本当に必要としているのが「旧 Range という具体型」ではなく「開始境界と終了境界」なら、RangeBounds まで抽象化できます。

use std::ops::{Bound, RangeBounds};

fn inspect<R>(range: R)
where
    R: RangeBounds<usize>,
{
    match range.start_bound() {
        Bound::Included(n) => println!("start included: {n}"),
        Bound::Excluded(n) => println!("start excluded: {n}"),
        Bound::Unbounded => println!("start unbounded"),
    }

    match range.end_bound() {
        Bound::Included(n) => println!("end included: {n}"),
        Bound::Excluded(n) => println!("end excluded: {n}"),
        Bound::Unbounded => println!("end unbounded"),
    }
}

fn main() {
    inspect(2..5);
    inspect(..5);
    inspect(2..);
    inspect(2..=5);

    inspect(core::range::Range {
        start: 2,
        end: 5,
    });
}

Rust 1.98系の RangeBounds には、旧 std::ops::Range<T> と新しい std::range::Range<T> の両方に実装があります。

そのため、境界情報だけ必要な公開関数なら、

fn inspect<R: RangeBounds<usize>>(range: R)

の形にすることで、新旧どちらのRangeからも呼べます。

ただし、何でも RangeBounds にすればよいわけではありません。

内部状態として「必ず半開区間の開始値と終了値を保持する」こと自体に意味があるなら、新しい具体的な Range<usize> を保持したほうが型の意味は明確です。

一方で、公開関数が ..5 や 2..=5 まで受け付けたいなら RangeBounds が合います。

つまり、考える単位を分けます。

保存する型
    新しい Range<T> など具体型

受け取る境界
    impl RangeBounds<T>

この区別は、今回の移行期間だけの回避策ではありません。内部表現と公開APIで必要な契約を分ける、という普通の設計判断です。

新Rangeの Copy も同じ話に見えます。旧Rangeでは「区間」と「反復中の状態」が一つの型に入っていました。新Rangeでは区間を値として保持し、反復状態を RangeIter に任せています。Span のように範囲そのものを小さな値として持ち回りたい場合、この責務分離がそのまま Copy の使いやすさにつながります。

ただし現在の範囲構文はまだ旧型を生成するため、既存コードを機械的に core::range::Range へ置き換える段階ではありません。構造体内部で Copy な範囲値が必要なのか、公開APIで範囲一般を受けたいのかを先に分け、後者なら RangeBounds を境界として使えるか確認するのが安全です。

参考

Rust 1.96.0 Release Announcement

core::range::Range

std::range

std::ops::Range

std::ops::RangeBounds

4
5
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?