Rust の Iterator で cumsum をどう書くべきか、あるいは map の闇

Rust の Iterator で cumsum をどう書くべきか、あるいは map の闇

最近、競プロ典型90問を解いて競プロのリハビリ兼 Rust 練習しています。

Iterator::scan の謎

cumulative sum といえば Haskell でいう scanl 0 (+) だと思っているので it.scan(0, |a, b| a + b) と書けば良いだろうと scan を見に行くと、予想に反する型がついている。型は少し省略して書くと

fn scan<St, B>(
    self,
    initial_state: St,
    f: impl FnMut(&mut St, Self::Item) -> Option<B>,
) -> impl Iterator<Item = B>

なのだが、 StB が異なっていることからも分かるように 0 番目に initial_state を返す実装ではないのが残念だし、逆に長さを縮める機能を Option を返すことで実装しているのも意図が分からない。

つまり、後者の目的で scan を使うくらいなら map_while (since 1.57) で十分だし、 _while 要素もないので map (FnMut(Self::Item) -> B を渡す) で良く、 cumsum は

std::iter::once(0)
    .chain(it.map({
        let mut s = 0;
        move |a| {
            s += a;
            s
        }
    }))

となる。

Iterator と FnMut

副作用のある関数を書いて良いのが盲点で、実際そういう言語だから map という名前で

traverse :: (Traversable t, Applicative f) => (a -> f b) -> t a -> f (t b)

あるいは mapM 相当だと思っておくのが自然だった。 Rust だと state monad 使う面倒さがなくて楽。めでたしめでたし。

とはいえ、 Fn/FnMut/FnOnce の区別みたいなところでも mut ref は unique ref という話みたいな対応もあって、 API 設計の意図が mut 側か unique 側かは分からない。副作用があっても実装が変わらないから FnMut とも言えるし、必要条件の側を考えて「同時に callback が呼ばれないから Fn を課す必要はなく、 Option::map などと違って 2 回以上呼ばれるかもしれないから FnOnce では足りない」という理由で FnMut になっているとも言える (これらは言い方が違うだけ)。

DoubleEndedIterator

Rust の Iterator のうち一部は後ろからも値を取り出せる (next_back)。たとえば、よく書くのは for i in (0..n).rev() だが、全部読んで Vec::reverse のような実装ではなく end -= 1 を返すような効率の良い実装が呼ばれる (std::ops::Range<A> については Iterator ならば DoubleEndedIterator)。

さて、 Iterator::map のドキュメントをよく見るともとの iterator が DoubleEndedIterator なら map 結果も DoubleEndedIterator を実装するらしい。見るからに副作用がやばい。そう思ってドキュメントを確認するともっと目立つところに具体例付きで注意書きがある。上の cumsum 実装も同じ理由で .rev() などすると壊れる。

.map(f).rev().rev().map(f) に直すように lint 出ても良いくらいだと思うが、 DoubleEndedIterator は 1 回 rev して終わりと限らず両端から自由な順序で取り出す想定の API だから pure function で map して DoubleEndedIterator を保ちたい用途がないとも言い切れない。

advanced_by

ところで、 for i in (0..n).step_by(k) は上限と modulo を指定できて便利なだけでなく O(n/k) 時間で動く。これは Iterator (required method は next のみ) は nth を自動実装しているが std::ops::Range が O(1) 時間の nth を与えることで実現されている。

自分で .map(f).rev() と書くことはないはずだが、 .map(f).step_by(k) は書きかねないので、 rev がやばいのなら step_by もやばいのではと思って調べたところ、これはセーフ。

Iterator::map が副作用無視の nth の高速化をしないことが保証されているか気になって iterator map nth で GitHub issue を検索したら advanced_by (unstable) の tracking issue の中で議論されていた。 map の副作用の注意書きすでにあるし nth でも副作用飛ばそうぜみたいな意見もあって一応予断を許さない雰囲気はちょっとある。競プロに限らなければ順序不定でもよい副作用とか消えても良い副作用とかいろいろあるので対立もやむなし。

立場をあえて明確にすれば自分は現状に賛成。 DoubleEndedIterator::rev は subtrait として分かれているから自己責任で、 Iterator::step_by が自動実装と挙動が違ったらやばすぎるみたいな裁定で正しいと思う。 Iterator::lastDoubleEndedIterator::next_back ではないし Iterator::countExactSizeIterator::len ではない。

AtCoder で cumsum したいとき

自分で書きたくないなら itertools_num::ItertoolsNum::cumsumndarray::ArrayBase::accumulate_axis_inplace を使えばよさそう。いずれも最初に 0 をつけたい場合はいじる必要あり。

cumsum を書く場合は map 後すぐ collect_vec() しておけば罠を踏むリスクはない。そもそも、ライブラリ整備せずその場で書くとなれば短いのが強く、

let mut s = vec![0];
// let n = a.len(); // 別のところで入力しているはず
// s.reserve(n); // Rustでそんなにパフォーマンス気にせずともACとれるはず
for i in 0..n {
    s.push(s[i] + a[i]);
}

とするか、 i, ntypo を避けて

let mut s = vec![0];
for a in &a {
    s.push(s.last().unwrap() + a);
}

にしそう。