Vim script でジェネレータを作ったり、遅延評価してみる

この記事は以下のページに移転しました.

blog.ryota-ka.me

この記事は CAMPHOR- Advent Calendar 2016 8日目の記事です.

はじめに

日本時間の2016年9月12日に,Vim 8.0 がリリースされた.Vim 7.4 のリリースからはおよそ3年振り,Vim 7.0 からは実におよそ10年振りのバージョンアップだそうだ.Vim 8.0 では様々な新機能が追加されたが,中でも Vim script にラムダ式が追加されたのには目を引くものがあった.

ラムダ式の登場により,標準の map() 関数や filter() 関数の使い勝手が改善されたが,これらで遊んでいるうちに,似た操作をリストだけではなくイテレータに対して適用したいという欲求が自然と生まれてきた.しかしながら,Vim script にはリストはあれど,イテレータなどというものは存在するはずもないので,今回自分で実装する運びとなった.

本稿では,まず初めに,ECMAScript 2015 のインターフェースに似た*1,すなわち,next を呼ぶと { value: 42, done: false } という形式に近い値が返ってくるイテレータVim script で実装する.次に,このイテレータを返す関数,すなわちジェネレータを定義する.その後,イテレータを拡張し,mapfilter などのよく知られたメソッドを定義することで,種々の操作を簡便に行えるようにする.

これまでの流れ

1 事前準備

イテレータ及びジェネレータを実装していくにあたり,事前に幾つか知識的な準備を行う.Vim script に詳しい読者は読み飛ばしてもらっても構わない.

1.1 ラムダ式

ラムダ式Vim 8.0 の新機能*2で,{ args -> expr } という形式で簡潔に関数を定義することができる.

let s:Double = { x -> x * 2 }
echo s:Double(3)
" 6

let s:Add = { a, b -> a + b }
echo s:Add(4, 5)
" 9

let s:AlwaysFourtyTwo = { -> 42 }
echo s:AlwaysFourtyTwo()
" 42

また,ラムダ式は,外側のスコープにある変数を参照できる.(つまりクロージャである.)以下では,2つの値を受け取り大きい方の値を返す,カリー化された Max() 関数を定義しているが,Max() のスコープにある a:xラムダ式の内部から参照できている様子が見て取れる.

function! Max(x)
  return { y -> a:x >= y ? a:x : y }
endfunction

echo Max(100)(50)
" 100

また,ラムダ式を他の関数に引数として渡すこともできる.標準の map() 関数*3とともに用いる例を挙げる.

let s:xs = [0, 1, 2, 3, 4]
call map(s:xs, { x -> x * 2 })
echo s:xs
" [0, 2, 4, 6, 8]

1.2 Vim script におけるオブジェクト指向風プログラミング

ES2015-like なイテレータのインターフェースを実装したいため,まずは Vim script 上で,オブジェクト指向プログラミングを行えるように下準備を行う必要がある.オブジェクト指向とは何かという議論はここではしない.しかしながら,オブジェクトに関連付けられた値を持つことで,内部状態を表現し,また,オブジェクトに付随する関数の中でオブジェクト自身 (いわゆる thisself) の状態を読み出したり,あるいは変更したりできるならば,それは凡そオブジェクトと呼んで差し支えないであろうと思われる*4.また,似たような種類の異なるオブジェクトを複数生成するために,新たなオブジェクトを返す関数(コンストラクタ)を用意しておくと便利である.

では,次の JavaScript のコードで示すような挙動を Vim script で実現してみよう.

class Person {
  constructor(name) {
    this.name = name;
  }

  greet() {
    console.log(`Hey, I'm ${this.name}!`);
  }
}

const morishin = new Person('morishin');
const kohey = new Person('kohey');

morishin.greet();
// Hey, I'm morishin!

kohey.greet();
// Hey, I'm kohey!

対応する Vim script のコードを以下に示す.

function! Person(name)
  let person = { 'name': a:name }

  function! person.greet()
    echo "Hey, I'm " . self.name . "!"
  endfunction

  return person
endfunction

let s:morishin = Person("morishin")
let s:kohey = Person("kohey")

call s:morishin.greet()
" Hey, I'm morishin!

call s:kohey.greet()
" Hey, I'm kohey!

Vim script には辞書というデータ構造があり(:help Dictionary),オブジェクトとして振る舞わせるにはうってつけだ.ここでは Person という関数を定義しているが,この関数は,引数として name を受け取り,それを 'name' というキーに関連付けて辞書に投げ込み,変数 person に代入しているが,この辞書こそがオブジェクトの実体である.

次に,この辞書に対して「メソッド」を定義している.まるで Ruby の特異メソッド定義のような書き方をしているが,このような記法を用いると,greet 関数を参照する Funcref を辞書の中に入れることができる.また,関数内では self を通じて辞書自身を参照できる.(詳細は :help numbered-function ないし :help anonymous-function を参照のこと.)

このように定義した Person 関数を通じて,我々は Person オブジェクトとでも呼ぶべき辞書を手に入れることができるようになった.

2 イテレータ・ジェネレータの実装

2.1 Nat ジェネレータ

事前の準備が済んだので,次にイテレータと(イテレータ・オブジェクトを返す関数である)ジェネレータを定義する.まずは,もっとも単純だと思われる,next メソッドを呼び出す度に,後続の自然数を返し続けるイテレータを返すジェネレータ Nat を定義してみることにする.

参考までに,ECMAScript での実装を先に示しておく.

function * Nat() {
  let i = 0;
  while (true) yield i++;
}

const nat = Nat();
nat.next(); // => { value: 0, done: false }
nat.next(); // => { value: 1, done: false }
nat.next(); // => { value: 2, done: false }

next メソッドを呼ぶと,valuedone という2つのキーを持ったオブジェクトが返ってくるが,Vim script ではオブジェクトの代わりに辞書を用いる.対応する実装は以下である.

function! Nat()
  let it = {}
  let n = -1

  function! it.next() closure
    let n += 1
    return { 'value': n, 'done': v:false }
  endfunction

  return it
endfunction

空の辞書を作成し it に代入したのち,この辞書上に next メソッドを定義しているが,鍵になるのは,function! it.next() の後ろにある closure である.これは Vim 8.0 の新機能*5で,closure を伴って宣言された関数は,クロージャとして振る舞う,すなわち,関数の外側のスコープに存在する変数にアクセスできるようになる.これを利用して,next は評価される度に,その外側にある n の値をインクリメントした上で,{ 'value': n, 'done': v:false } という辞書を返すようにしてある.ここで,done に常に v:false を入れて返すのは,これまでにどれだけの値を yield したかに関わらず,常に次の値を yield できるということであり,任意の自然数について,その後者が存在することに対応している.そして,Nat 自体は,こうして next メソッドを定義されたイテレータを返す.

こうして定義した Nat ジェネレータから得られるイテレータが,期待通り振る舞うか確かめてみよう.

let s:nat = Nat()

echo s:nat.next()
" {'done': v:false, 'value': 0}

echo s:nat.next()
" {'done': v:false, 'value': 1}

echo s:nat.next()
" {'done': v:false, 'value': 2}

echo s:nat.next()
" {'done': v:false, 'value': 3}

2.2 Range ジェネレータ

Vim script には range() 関数が存在する.ヘルプ (:help range) の記述は以下の通りだ.

range({expr} [, {max} [, {stride}]])             *range()*
        Returns a |List| with Numbers:
        - If only {expr} is specified: [0, 1, ..., {expr} - 1]
        - If {max} is specified: [{expr}, {expr} + 1, ..., {max}]
        - If {stride} is specified: [{expr}, {expr} + {stride}, ...,
          {max}] (increasing {expr} with {stride} each time, not
          producing a value past {max}).
        When the maximum is one before the start the result is an
        empty list.  When the maximum is more than one before the
        start this is an error.
        Examples: >
            range(4)        " [0, 1, 2, 3]
            range(2, 4)     " [2, 3, 4]
            range(2, 9, 3)      " [2, 5, 8]
            range(2, -2, -1)    " [2, 1, 0, -1, -2]
            range(0)        " []
            range(2, 0)     " error!

関数のインターフェースとしては,よくありそうなやつである.こいつは標準の関数なので,もちろんリストを返すのだが,イテレータを返すバージョンの Range を実装してみよう.

function! Range(expr, ...)
  let it = {}

  if a:0 == 0
    let [n, max, stride] = [0, a:expr - 1, 1]
  elseif a:0 == 1 || a:0 == 2
    let [n, max, stride] = [a:expr, a:1, a:0 == 1 ? 1 : a:2]
  else
    throw 'wrong number of arguments: Range({expr} [, {max} [, {stride}]])'
  endif

  function! it.next()
    let value = n
    let done = n > max ? v:true : v:false

    let n += stride

    return { 'value': done ? v:none : value, 'done': done }
  endfunction

  return it
endfunction

Nat の場合と違って,今回は有限列である.なので,next を呼ぶ度に終了条件を確認し,戻り値の辞書の done キーに含めている.また,返すべき値を持たない際には,v:none を返すようにしている.*6

let s:r1 = Range(3)

echo s:r1.next()
" {'done': v:false, 'value': 0}

echo s:r1.next()
" {'done': v:false, 'value': 1}

echo s:r1.next()
" {'done': v:false, 'value': 2}

echo s:r1.next()
" {'done': v:false, 'value': v:none}

echo s:r1.next()
" {'done': v:false, 'value': v:none}
let s:r2 = Range(3, 5)

echo s:r2.next()
" {'done': v:false, 'value': 3}

echo s:r2.next()
" {'done': v:false, 'value': 4}

echo s:r2.next()
" {'done': v:false, 'value': 5}

echo s:r2.next()
" {'done': v:true, 'value': v:none}

echo s:r2.next()
" {'done': v:true, 'value': v:none}
let s:r3 = Range(5, 10, 2)

echo s:r3.next()
" {'done': v:false, 'value': 5}

echo s:r3.next()
" {'done': v:false, 'value': 7}

echo s:r3.next()
" {'done': v:false, 'value': 9}

echo s:r3.next()
" {'done': v:true, 'value': v:none}

echo s:r3.next()
" {'done': v:true, 'value': v:none}

3 イテレータのメソッドを定義する

無事にジェネレータを定義できたので,次は趣向を変えて,イテレータ自体の利便性を高めていく.そのために,イテレータnext 以外のメソッドを実装していくことにしよう.

3.1 Iterator コンストラク

イテレータの出所が,Nat であれ Range であれ,あるいは他に定義したジェネレータであれ,すべてのイテレータが共通のメソッドを持っていてほしいというのは自然な欲求であろう.これを実現するために,拡張されたイテレータを生み出すための関数を先に定義しておく.上で見たように,イテレータの実体は next というキーに {'done': v:false, 'value': 42} といった値を返す関数が入った単なる辞書であるから,イテレータ自身の振る舞いは,この関数ひとつで表現できるはずなので,これを引数として取り,拡張されたイテレータを返す関数があればよい.

function! Iterator(next)
  let it = { 'next': a:next }

  function! it.methodA(...)
    " do something
  endfunction

  function! it.methodB(...)
    " do something
  endfunction

  function it.methodC(...)
    " do something
  endfunction

  return it
endfunction

これに伴い,上で見た Nat および Range の定義も修正しておく.

function! Nat()
  let n = -1

  function! Next() closure
    let n += 1
    return { 'value': n, 'done': v:false }
  endfunction

  return Iterator(funcref('Next'))
endfunction
function! Range(expr, ...)
  if a:0 == 0
    let [n, max, stride] = [0, a:expr - 1, 1]
  elseif a:0 == 1 || a:0 == 2
    let [n, max, stride] = [a:expr, a:1, a:0 == 1 ? 1 : a:2]
  else
    throw 'wrong number of arguments: Range({expr} [, {max} [, {stride}]])'
  endif

  function! RangeNext() closure
    let value = n
    let done = n > max ? v:true : v:false

    let n += stride

    return { 'value': done ? v:none : value, 'done': done }
  endfunction

  return Iterator(funcref('RangeNext'))
endfunction

Next 関数*7を定義したのち,funcref() 関数で Funcref を得て,これを Iterator に渡している.funcref()Vim 8.0 で新たに追加された*8関数で,function() と同じように Funcref を得るための関数だが,function() は名前で検索するために,同名の関数が再定義された際に,新しいものを指してしまうが,funcref() は参照で検索するため,このようなことが起こらない.(詳しくは :help funcref を参照のこと.)

以上で準備が整ったので,実際にメソッドを定義していく.

3.2 toList メソッド

まず初めに実装するのは toList メソッドである.イテレータtoList メソッドを呼び出すと,イテレータの中に残っている値をすべて吐き出し,リストにして返す.ECMAScriptArray.from()イテレータに適用するようなものだと考えてもらえればいいだろう.(以下ではメソッド定義部分だけを抽出している.上で示した Iterator 関数の,function! it.methodA(...) などの部分に対応している.)

function! it.toList()
  let xs = []

  while v:true
    let x = self.next()

    if x.done
      return xs
    endif

    call add(xs, x.value)

    unlet x
  endwhile
endfunction

while v:true ~ endwhile で無限ループを回しながら,リスト xs に要素を追加していき,x.done が真に評価された時点で xs を返す.挙動を確かめてみよう.

echo Range(10).toList()
" [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

echo Range(25, 34).toList()
" [25, 26, 27, 28, 29, 30, 31, 32, 33, 34]

echo Nat().toList()
" won't stop

3.3 take メソッド

上で見たように,Nat() は無限列なので,リストに変換しようとすると評価が終了しないが,列の要素を確認するために,列の先頭の要素を一部だけ得るといった操作ができると便利である.そこで,次に take メソッドを定義する.

function! it.take(n)
  let i = 0

  function! Next() closure
    if i < a:n
      let i += 1
      return self.next()
    endif

    return { 'value': v:none, 'done': v:true }
  endfunction

  return Iterator(funcref('Next'))
endfunction

最初の n 回の呼び出しだけ値を yield するような関数 Next を定義し,これを Iterator コンストラクタに渡して,新たに生成したイテレータを返している.

echo Nat().take(10).toList()
" [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

3.4 map メソッド

次に,既存の列の各要素に,与えられた関数を適用して,新たな列を返す map メソッドを定義する.

function! it.map(f)
  function! Next() closure
    let x = self.next()

    if x.done
      return { 'value': v:none, 'done': v:true }
    else
      return { 'value': a:f(x.value), 'done': v:false }
    endif
  endfunction

  return Iterator(funcref('Next'))
endfunction

maptake を用いれば,無限列である Nat() のすべての要素に関数を適用したのちに,その一部分だけを取り出しているかのように振る舞わせることができる.

echo Nat().map({ x -> x * 2 }).take(10).toList()
" [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]

3.5 find メソッド

後述の filter メソッドのために,先に find メソッドを定義しておく.これは,述語 (predicate) を受け取り,評価結果が真であるような最初の要素を返すメソッドである.

function! it.find(f)
  while v:true
    let x = self.next()

    if x.done
      return v:none
    elseif a:f(x.value)
      return x.value
    endif

    unlet x
  endwhile
endfunction

単にループの中で,与えられた述語を満たす要素を探して返しているだけであるが,該当する要素が見つからなかった場合には v:none を返す.

echo Nat().find({ x -> x * x > 100 })
" 11

echo Range(10).find({ x -> x == 42 })
" v:none

3.6 filter メソッド

最後に,述語を受け取り,評価結果が真であるような要素のみを yield するイテレータを返す filter メソッドを実装する.

function! it.filter(f)
  function! Next() closure
    let value = self.find(a:f)

    return { 'value': value, 'done': type(value) == type(v:none) }
  endfunction

  return Iterator(funcref('Next'))
endfunction

これは,先程定義した find メソッドを用いて簡単に定義できる.find から v:none が返ってきた場合には donev:true が入るようになっている.

echo Nat().filter({ x -> x % 3 == 0 }).take(5).toList()
" [0, 3, 6, 9, 12]

3.7 Fib ジェネレータへの操作

さて,道具立てが整ったので,このシリーズでは毎度おなじみの,フィボナッチ数列の要素をそれぞれ二乗し,そのうち奇数のもののみを先頭から10要素取り出したリストを作ってみる.

まずは,フィボナッチ数列を得るためのジェネレータ Fib を定義する.

function! Fib()
  let [a, b] = [0, 1]

  function! Next() closure
    let value = a
    let [a, b] = [b, a + b]

    return { 'value': value, 'done': v:false }
  endfunction

  return Iterator(funcref('Next'))
endfunction

Fib が返すイテレータに対し,メソッド・チェーン・スタイルで行いたい操作を書けば,所望の結果が得られる.

echo Fib().map({ x -> x * x }).filter({ x -> x % 2 == 1 }).take(10).toList()
" [1, 1, 9, 25, 169, 441, 3025, 7921, 54289, 142129]

4 コード全文

今回使用したコードをまとまった形で書いておく.なお,上記で定義したメソッド以外にも,幾つかのメソッドが追加で定義されている.

function! Iterator(next)
  let it = { 'next': a:next }

  function! it.concat(other)
    function! Next() closure
      let x = self.next()
      if !x.done
        return { 'value': x.value, 'done': v:false }
      endif

      let y = a:other.next()
      if !y.done
        return { 'value': y.value, 'done': v:false }
      endif

      return { 'value': v:none, 'done': v:true }
    endfunction

    return Iterator(funcref('Next'))
  endfunction

  function! it.count()
    return len(self.toList())
  endfunction

  function! it.drop(n)
    for i in range(a:n)
      call self.next()
    endfor

    return self
  endfunction

  function! it.filter(f)
    function! Next() closure
      let value = self.find(a:f)

      return { 'value': value, 'done': type(value) == type(v:none) }
    endfunction

    return Iterator(funcref('Next'))
  endfunction

  function! it.find(f)
    while v:true
      let x = self.next()

      if x.done
        return v:none
      elseif a:f(x.value)
        return x.value
      endif

      unlet x
    endwhile
  endfunction

  function! it.flatMap(f)
    let iters = self.map(a:f)
    let iter = iters.next()

    function! Next() closure
      while v:true
        if iter.done
          return { 'value': v:none, 'done': v:true }
        endif

        let x = iter.value.next()

        if x.done
          let iter = iters.next()
          continue
        endif

        return { 'value': x.value, 'done': v:false }
      endfor
  endfunction

    return Iterator(funcref('Next'))
  endfunction

  function! it.head()
    return self.find({ -> v:true })
  endfunction

  function! it.map(f)
    function! Next() closure
      let x = self.next()

      if x.done
        return { 'value': v:none, 'done': v:true }
      else
        return { 'value': a:f(x.value), 'done': v:false }
      endif
    endfunction

    return Iterator(funcref('Next'))
  endfunction

  function! it.reduce(init, f)
    let xs = self.toList()
    let acc = a:init

    for x in xs
      let acc = a:f(acc, x)
    endfor

    return acc
  endfunction

  function! it.take(n)
    let i = 0

    function! Next() closure
      if i < a:n
        let i += 1
        return self.next()
      endif

      return { 'value': v:none, 'done': v:true }
    endfunction

    return Iterator(funcref('Next'))
  endfunction

  function! it.toList()
    let xs = []

    while v:true
      let x = self.next()

      if x.done
        return xs
      endif

      call add(xs, x.value)

      unlet x
    endwhile
  endfunction

  function! it.zip(other)
    return self.zipWith({ x, y -> [x, y] }, a:other)
  endfunction

  function! it.zipWith(f, other)
    function! Next() closure
      let x = self.next()
      let y = a:other.next()

      if x.done || y.done
        return { 'value': v:none, 'done': v:true }
      endif

      return { 'value': a:f(x.value, y.value), 'done': v:false }
    endfunction

    return Iterator(funcref('Next'))
  endfunction

  return it
endfunction

function! Nat()
  let n = -1

  function! Next() closure
    let n += 1
    return { 'value': n, 'done': v:false }
  endfunction

  return Iterator(funcref('Next'))
endfunction

function! Range(expr, ...)
  if a:0 == 0
    let [n, max, stride] = [0, a:expr - 1, 1]
  elseif a:0 == 1 || a:0 == 2
    let [n, max, stride] = [a:expr, a:1, a:0 == 1 ? 1 : a:2]
  else
    throw 'wrong number of arguments: Range({expr} [, {max} [, {stride}]])'
  endif

  function! RangeNext() closure
    let value = n
    let done = n > max ? v:true : v:false

    let n += stride

    return { 'value': done ? v:none : value, 'done': done }
  endfunction

  return Iterator(funcref('RangeNext'))
endfunction

function! Fib()
  let [a, b] = [0, 1]

  function! Next() closure
    let value = a
    let [a, b] = [b, a + b]

    return { 'value': value, 'done': v:false }
  endfunction

  return Iterator(funcref('Next'))
endfunction

おわりに

本稿では,Vim 8 で実装された新しい言語機構を用いて,Vim script によるジェネレータ・イテレータの実装を行った.これまでの「ジェネレータを作ったり、遅延評価してみる」シリーズが,言語内の既存の実装をなぞるものだったのに対し,今回は,与えられた言語の中で,新たに機能の体系を築き上げた点において,有意義な内容になったのではないかと思う.

今回取り上げた内容自体については,実は Vim 8.0 リリース当初から構想があり,早いうちから実装の基本部分は終えていた.その頃には 8.0.0005 だったバージョン番号も,今や 8.0.0124 となっており,活発な開発状況を感じさせられる.(なお,上記のコードの動作検証は Vim 8.0.0124 で行っている.)

余談ではあるが,今回の実装にあたって,Vim 8.0 から導入された test-functions を使用した.これは,Vim プラグインなどの開発者向けに導入されたものだそうだが,今回のケースでも,assert_equal() 関数は非常に重宝した.

CAMPHOR- Advent Calendar 2016 9日目の担当は @shotarok です.お楽しみに!

*1:あくまで似ているだけで,互換ではない.特に,next メソッドが引数を受け取ることを想定しない.

*2:正確には Vim 7.4.2044

*3:破壊的で残念

*4:もちろん,異なるオブジェクトの特徴付けがあってもよいが.

*5:正確には Vim 7.4.2120

*6:v:none の用途として間違っているだろういう意見は真っ当と思われるが,他にそれらしき値もないのでしようがない.

*7:Range の場合は,手元にある assertion を実行した際に,Next という名前だとエラーになってしまったので,RangeNext という名前にしてある.

*8:正確には Vim 7.4.2137