変わり者の Vim 組み込みオペレーター達

本記事はVimアドベントカレンダー2022 その3の24日目の記事です。

オペレーターというのはVimの編集コマンドの一群で、よく自然言語における動詞に例えられる。つまり編集の種類を指し示すコマンドである。もう少し具体的に言うと、「消す」であったり「コピーする」であったりするわけだが、「何を」消したりコピーしたりするのかオペレーター自身は知らない。「何を」はもちろんテキストであって、これは編集中のテキストファイルの「どこを」と言い換えてもよい。これは範囲を指定する他のコマンド(モーションやテキストオブジェクト)を使ってVimに伝えることができる。つまり、オペレーターとモーションは組み合わせてつかうことが前提のコマンドなのである。いくつかのコマンドを覚えると、その組み合わせによって多彩な編集を短いキーストロークで実現することが可能になるVimの愛すべき機能である。 (:help operator)

この記事では変わり者のオペレーターコマンドを紹介しようとおもう。 とはいえ、珍しいオペレーターの紹介ではない。 慣れ親しんだ(かどうかはわからないが)組み込みのオペレーターコマンドの奇妙な一面を紹介するのが趣旨である。 もしかすると細かすぎてほとんどの人は気にならないと思うが、まあ、そんな見方もあるのだな、ぐらいに思ってもらえれば幸いである。

c

最初は c コマンドである。 多くが使うであろう、メジャーといっていいオペレーターだ。

c コマンドは指定された範囲の文字列を削除して挿入モードに入り、入力された文字列に置き換える。 さらに . コマンドを使うと削除+挿入をまとめて繰り返すことができる。 c の特殊性はまさにこの . コマンド時の挙動にある。 例えば、d コマンドと i コマンドを順に使っても同じ編集はできるが、その場合 . コマンドで繰り返そうとしても繰り返されるのは i コマンドによる文字列の挿入のみとなってしまう。

ドットコマンドの繰り返す編集単位

. コマンドは基本的に単発の編集操作を繰り返す。 そしてこの編集操作はノーマルモードから挿入モードに入るときに区切られてしまい、「文字列を消す」と「文字列を挿入する」という2つの編集をまとめて繰り返すことはできないのである。 c コマンドを除いては。

今の所、この挿入モード開始時に編集操作が区切られるというのはほぼ絶対のルールである。 現在のVim scriptの提供する機能ではユーザー定義オペレーターで c の挙動を真似することはまともな方法ではできない。 変わり者というよりも特別なオペレーターだと言ってよいだろう。

y

続いて y コマンドだが、こちらも超メジャーだ。 こちらはVimのオペレーターという仕組みを知らない人でも使うコマンドであろう。

y コマンドは指定された範囲の文字列をヤンクする。 より一般的な語彙を使うなら文字列のコピーを司るオペレーターである。 y コマンドの特殊な点は、やはり . コマンド時の挙動に表れる。 端的に言って y コマンドは唯一「. コマンドで繰り返さない」ことができるオペレーターなのである。 :help cpo-y するとこのようなことが書いてある。

                             *cpo-y*
y   コピーコマンドを "." でリドゥできる。本当にこれを使い
    たいのであれば、2度考えること。いくつかのプラグインを
    壊すかもしれない。ほとんどの人が、"." が変更を繰り返す
    ことを期待しているからである。

デフォルトでは cpo、つまり 'cpoptions'y は含まれない。 つまり、ほとんどの人は y. コマンドで繰り返さない状態で使っており、おそらくそれが自然な挙動だと考えている。 しかし、この挙動は異常だ。 (特にプラグイン作者にとっては。) なぜなら、この挙動はユーザー定義オペレーターでは真似できないのである。 挿入モードへ入るタイミングがそうであったように、オペレーターコマンドの発動は必ず編集操作を区切り、絶対に . コマンドで繰り返されることになる。

オペレーターの最も重要な性質は . コマンドによる繰り返し可能な点である、というのは多くの Vimmer が賛成するところだと思っている。 とするならば、この話は奇妙に聞こえるかもしれない。 しかし「繰り返さない」ことができないというのも大きな制限だと私は思う。 目下のところ、この一点において y コマンドの模倣もまたできないのである。


私としては現状 cy はシステム的に特別扱いされているオペレーターという認識だ。 少なくともVim scriptのレイヤーではこれらを模倣することはできない。

現在のVim script における . コマンドまわりのインターフェースはあまり簡単ではないし、このように十全とも言い難い。 将来的には . コマンド制御のための API がもっと提供されてほしいと思う。 それがどんなものになるかは分からないが、cy が特別でなくなると嬉しい。

multitarget-gn.vim を書きました

先に断っておくと、私としてはこれは邪道だと思っている。

GitHub - machakann/vim-multitarget-gn: Yet another gn command taking a count as a number of operation

これ何

Vim の組み込みテキストオブジェクト gn の亜種である。

gn って何

:help gn

を参照のこと。

gn は最後に検索したパターンにマッチする文字列のうち、カーソル前方最寄りのものを対象とするテキストオブジェクトである。 cgn などとして文字列を置換した後、. を連打するとどんどん文字列が置換されるので、数が少なければ :s コマンドよりも手軽で使い勝手が良い。 現在では組み込みのテキストオブジェクトとなっているが、歴史的にはkana氏のプラグインvim-textobj-lastpatが本体に逆輸入されたものである。 動作を理解するハードルがかなり高く、私も最初は使い方すらよくわからなかった。 しかし、氏こそ私にとってのスタープレイヤーだったし熟練のVimmerが愛用していると漏れ聞いていたので信じて試行錯誤して使い方を覚えた。 便利さに気がついた瞬間の感動は忘れられない。

オリジナルのgnとの違い

基本的にノーマルモードでの動きはオリジナルと同じ挙動をとり、ビジュアルモードではちょっと異なる挙動をとる。 とはいえこの違いはそんなに重要ではないので後述することにする。 最大の焦点はオペレーター待機モードでの動作である。

例えば次のようなバッファがあり、すべての foo をバッファから消したいとする。

foo bar
bar foo
foo bar

/foo<CR> などしてすでに対象を検索した状態で、カーソルが1行目の1列目にある。 このとき 3dgn とするとどうなるだろうか。 答えはこうである。

foo bar
bar foo
 bar

宜なるかな、これがあるべき挙動だと思う。 3つ目 の候補を消すというわけだ。 しかし、以下のような挙動を期待する人間もいるのだ。

 bar
bar 
 bar

こちらは 3つ の候補を消している。 ちなみに私はこれを期待してカウントを与えたことがある。 私以外にもいるようである。

"gn" with [count] is useless · Issue #632 · vim/vim · GitHub

テキストオブジェクトとしては前者のオリジナルの挙動が正しいと思う。 しかし、後者ができれば便利というのもわかる。 というわけで、できるようにしたのが今回のプラグインというわけだ。

使い方

自分でマップして使ってください。

nmap gn <Plug>(multitarget-gn-gn)
xmap gn <Plug>(multitarget-gn-gn)
omap gn <Plug>(multitarget-gn-gn)

nmap gN <Plug>(multitarget-gn-gN)
xmap gN <Plug>(multitarget-gn-gN)
omap gN <Plug>(multitarget-gn-gN)

動作要件

  • v8.2.0877かそれより新しい Vim
  • SafeState autocmd
  • +textprop feature

各モードでの挙動

ノーマルモード

オリジナルと同じになることを意図している。

ビジュアルモード

ビジュアルモードで使用した場合、オリジナルの gn コマンドは次の検索対称を含むまで選択範囲を 拡張 する。これに対して multitarget-gn は次の対象に ジャンプ してその対象のみを選択する。とはいえどっちも使ったことない。

オペレーター待機モード

カウント n が与えられた場合、オリジナルの gn コマンドはカーソル位置から n 個目 の検索対象を編集する。これに対して multitarget-gn は n 個 の対象を編集する。

余談

基本的にテキストオブジェクトとはカレントバッファ上の一部のテキストを指定する機能である。 ユーザー定義テキストオブジェクトを実装する場合、テキストの指定はビジュアル選択を使って行われる。 このため、テキストオブジェクトの指定する範囲には次のような制約がある。

  • カレントバッファ上のテキストであること。
  • 2つの座標と範囲形状の情報のみで表現できること。

前者については特に解説の必要はないと思う。 後者における座標とは行番号と列番号の組 [lnum, col]のことであり、範囲形状は3つのビジュアルモードに対応する、つまり文字単位、行単位、矩形のどれかになる。 さて、multitarget-gnは一見テキストオブジェクトっぽく振る舞うが明らかに後者のルールを破っている。 つまり、multitarget-gnが編集する範囲を表現するにはどうしても2つの座標では足りない。 この点において、冒頭の「邪道」であったり、「テキストオブジェクトとしては前者のオリジナルの挙動が正しいと思う」という表現があったというわけである。 同じ理由でテキストオブジェクトプラグインに慣習的につけられる textobj- というプレフィックスもつけなかった。 なのでこのプラグインはまあ、テキストオブジェクトっぽいものと言うことで。

最初のコミットは今とは違う方式で実装されていたが、欠陥が多かったため次のコミットでほぼ書き直された。 この2つめのコミットの時点で基本的な機能は完成していたと思うが、コーナーケースを潰すためにどんどんコードが増えてしまったのは悩ましい。 そもそも、キーストロークとは反対に処理の順番はテキストオブジェクトが先でオペレーターが後なのでほとんどの点においてオペレーターに主導権がある。 テキストオブジェクトからみたオペレーターは指定したテキストをこれから編集するかもしれないし、しないかもしれない、テキストを削除するかもしれないし、挿入するかもしれない、なんならテキストオブジェクトの指定した編集範囲を無視することすらできる、とにかく何をするかわからないやべーやつなのだ。 ユーザー定義オペレーターを考慮に入れるといくらでもコーナーケースを思いつくし、100%の実装ってない気がする。 せめてオペレーター毎の専用設定を可能にするぐらいならできるだろうけれど。

複数範囲を対象に取るテキストオブジェクトっぽいもののアイデアはずっとあった。 しかし、いくつかの技術的な問題から無視できない欠陥があり塩漬けしていた。 例えばオペレーターの処理が終わったあとに確実に処理をフックする方法が昔はなかった。 オペレーターは必ず編集をするとは限らないので TextChanged では満足できなかった。 今ではこの問題はおおむね SafeState が解決したと思うが、タイマーでも代用できるかもしれない。 またユーザー定義オペレーターを考慮に入れると処理後のカーソル位置は予測不可能なので、できれば次の編集位置は追跡したいがマークは汚したくない、など。 今回考え直してみると、SafeState イベントや text property の登場で気になっていた主なダメケースがそこそこ潰せることに気がついたので書いた。

やっとアイデアを供養できたのでちょっとすっきり。

ちなみに

言うまでもなくオペレーターと組み合わせて使用された場合にドットリピート可能である。

C++ 開発で LAPACK を使ったエルミート行列対角化をする

はじめに

C++ 開発で LAPACK を使う場合のための覚書として記す。 ソースコードとコンパイルオプションを明示して、最低限動かすために必要な情報を残す。 また、REFERENCE BLAS と OpenBLAS の速度比較も行う。

BLAS と LAPACK

BLAS は Basic Linear Algebra Subprograms の略で線形代数に必要な基礎的な演算を高速に行う数値計算ライブラリである。 これには内積やノルムの計算、ベクトルや行列の乗算などが含まれている。

BLAS にはいくつかのバリエーションがあり、Netlib で公開されているものが公式の BLAS となる。 これは特に REFERENCE BLAS とも呼ばれている。 つまり、すべての BLAS の「お手本」である。 ドキュメントが整っており、どのバリエーションを使う場合も Netlib のドキュメントを確認すればよい。 REFERENCE BLAS は高速な計算アルゴリズムを示すためのもので、コードは可読性に重点を置かれている。 これに対して、より速度に重点をおいて種々の最適化を施された BLAS を最適化 BLAS (Optimized BLAS) という。 最適化 BLAS には現在有名なものに MKL (Intel Math Kernel Library)、OpenBLAS などがある。 (より正確に言えば MKL は BLAS 以外にも LAPACK 相当の機能も含んでおり、更に多くの数値計算ルーチンを包含している。)

LAPACK は Linear Algebra PACKage の略でより一般的な線形代数の問題を解くための数値計算ライブラリである。 これには連立1次方程式、固有値問題、特異値分解をはじめとして、それらを解くために必要となる多くの補助ルーチンが含まれる。

LAPACK は BLAS のルーチンを使って計算を行うために必ずこれが必要になる。 もちろん REFERENCE BLAS でもよいが、最適化 BLAS を使うことで LAPACK の計算も高速になる。

エルミート行列対角化のためのLAPACKドライバー

LAPACK は 1000 を超えるルーチンからなり、それらを組み合わせて線形代数の諸問題を数値的に計算するためのライブラリである。 多くのルーチンを組み合わせて一般的な問題(連立1次方程式、固有値問題、特異値分解など)を解くためのルーチンは特別にドライバー(driver routine)と呼ばれている。

エルミート行列の対角化には zheev 系のドライバーを用いる。 「系」というのには理由があり、同じ問題でも実装によっていくつかの選択肢が存在するためである。

  • zheev
    • Simple driver
    • 最も単純で初期から存在するドライバー
  • zheevx
    • Expert driver
    • Simple driver とアルゴリズムは同じだが、固有値を指定の範囲・個数まで求めて計算を打ち切るための仕組みが提供されている
      • 固有値が全部必要ない場合には計算が早く終了することが期待できる
    • 基本的には Simple driver の上位互換と考えて良い
  • zheevd
    • Divide and conquer アルゴリズムによって固有値問題を解くドライバー
  • zheevr
    • Relatively Robust Representation アルゴリズムによって固有値問題を解くドライバー
    • 比較的新しく、v3.10.1 時点ではまだ実装は完全ではないようである
      • 具体的に言うと引数 ABSTOL のドキュメントに少し書いてある
      • しかし、十分に使えるようではある
    • 高速かつ Expert driver と同じく一部の固有値のみを計算することができる

サンプルソースコードでは 1000x1000 のランダムなエルミート行列を対角化するプログラムを示す。 LAPACK はもともと FORTRAN 言語でかかれており、その呼び出し規則に従わなければならない。 つまり、行列は Column major で連続したメモリ上に配置されていなければならない。 C/C++ のプログラムでは1000*1000=1000000要素の1次元配列としてメモリを確保し、Column majorで数値を格納する。


コンピュータのメモリ空間は1次元的な配置になっているので、 2次元の行列をメモリ上に展開するには何らかの規則が必要である。 Column majorとは以下のような行列

 
\begin{matrix}
a_{11} & a_{12} & a_{13} \\
a_{21} & a_{22} & a_{23} \\
a_{31} & a_{32} & a_{33}
\end{matrix}

を配列Aに次のように配置することである。

 
A[0] = a_{11} \\
A[1] = a_{21} \\
A[2] = a_{31} \\
A[3] = a_{12} \\
A[4] = a_{22} \\
A[5] = a_{32} \\
A[6] = a_{13} \\
A[7] = a_{23} \\
A[8] = a_{33}

Cにおける配列(C++における生配列)はメモリ上に連続的にデータを配置する。 つまり、各列 (column) をメモリ上に連続的に配置している。 Column major と対になる言葉として Row major というものがある。 これは同じ行列を次のように配置することである。

 
A[0] = a_{11} \\
A[1] = a_{12} \\
A[2] = a_{13} \\
A[3] = a_{21} \\
A[4] = a_{22} \\
A[5] = a_{23} \\
A[6] = a_{31} \\
A[7] = a_{32} \\
A[8] = a_{33}

つまり、各行 (row) をメモリ上に連続的に配置している。


下記のプログラムではランダムなエルミート行列を使っているので特に注意されていないが、 本来であれば Column/Row major はLAPACKドライバーを使う上では最も注意が必要な点と思われる。 (今回はエルミート行列なので転地しても固有値は変わらないはずである。)

C/C++ から FORTRAN でかかれたサブルーチンを呼び出すためには、FORTRAN でのサブルーチン名の最後にアンダースコア(_)をつけなければいけない。 つまり、zheev ドライバーを呼び出すためには zheev_ と記述する。

また、計算には上三角行列あるいは下三角行列のどちらかしか使わないので、対角化する行列を作る際は全部の要素を埋める必要はない。 つまり、エルミート対称性から対角要素を挟んで逆側の値は決まるので、原理的に計算に必要ないのである。 少しでもプログラムを高速化するためには重要である。

zheevドライバー

ドキュメント: https://netlib.org/lapack/explore-html/df/d9a/group__complex16_h_eeigen_gaf23fb5b3ae38072ef4890ba43d5cfea2.html

void zheev_(const char& JOBZ, const char& UPLO, const int& N, complex* A,
            const int& LDA, double* W, complex* WORK, const int& LWORK,
            double* RWORK, int& INFO);

引数 A に対角化する行列を与えると W に与えた配列に計算された固有値が格納される。 配列 A は対角化計算の過程で破壊的変更を受ける。 つまり計算が終了した時点で A は与えた配列と同一の内容ではないので注意。 JOBZ'V' を指定した場合は A の第i列が Wi番目の要素に対応した固有ベクトルになっている。

WORK, RWORK は計算のために必要な作業用のメモリ領域であり、LWORKWORK の配列サイズである。 RWORK は NxN の行列を対角化する場合、3N-2 のサイズ決め打ちでよい。 WORK の最適なサイズは LWORK に -1 を与えて zheev を呼び出すと計算でき、WORK の最初の要素に格納される。 つまり、固有値を得るためには 1. LWORK の決定 → 2. 固有値の計算というように2回 zheev を呼び出す。 それぞれ最適サイズより大きくても問題ない。 最適サイズより小さい場合、計算速度が悪化することが予想される。

何度も固有値問題を解く場合、WORK, RWORK はサイズが十分な限り使い回せる。 すなわち、ブロック対角化のように何回か対角化をする場合、最も大きい行列で WORK, RWORK を用意しておけば何度も確保し直す必要はない。

計算終了後に INFO に計算の終了状態に関する情報が返される。 INFO = 0 なら正常終了。 INFO < 0 なら INFO 番目の引数に問題があり、異常終了。 INFO > 0 なら計算が収束しなかったことを意味する。

zheevxドライバー

ドキュメント: https://netlib.org/lapack/explore-html/df/d9a/group__complex16_h_eeigen_gaabef68a9c7b10df7aef8f4fec89fddbe.html

void zheevx_(const char& JOBZ, const char& RANGE, const char& UPLO,
             const int& N, complex* A, const int& LDA, const double& VL,
             const double& VU, const int& IL, const int& IU,
             const double& ABSTOL, int& M, double* W, complex* Z,
             const int& LDZ, complex* WORK, const int& LWORK, double* RWORK,
             int* IWORK, int* IFAIL, int& INFO);

引数 A に対角化する行列を与えると W に与えた配列に計算された固有値が格納される。 配列 A は対角化計算の過程で破壊的変更を受ける。 つまり計算が終了した時点で A は与えた配列と同一の内容ではないので注意。 JOBZ'V' を指定した場合は Z の第i列が Wi番目の要素に対応した固有ベクトルになっている。

zheevx ドライバーは与えられた行列 A の固有値の内の一部のみを計算する機能を持つ。 すべての固有値が必要ではない場合には、計算を打ち切って高速化することができる。 実際に見つかった固有値の個数は M に格納される。

  • RANGE'A' を指定した場合、すべての固有値を計算する。
  • RANGE'V' を指定した場合、(VL, VU] の半開区間に入る固有値のみを求める。
  • RANGE'I' を指定した場合 IL 番目から IU 番目の固有値のみを求める。

ABSTOL は固有値の許容計算誤差に関する引数だが、特に理由がなければ最良の精度を得るために 2*DLMCH('S') を与えるのがよい。

WORK, RWORK, IWORK は計算のために必要な作業用のメモリ領域であり、LWORKWORK の配列サイズである。 RWORK は NxN の行列を対角化する場合、7*N のサイズ決め打ちでよい。 IWORK は NxN の行列を対角化する場合、5*N のサイズ決め打ちでよい。 WORK の最適なサイズは LWORK に -1 を与えて zheevx を呼び出すと計算でき、WORK の最初の要素に格納される。 つまり、固有値を得るためには 1. LWORK の決定 → 2. 固有値の計算というように2回 zheevx を呼び出す。 それぞれ最適サイズより大きくても問題ない。 最適サイズより小さい場合、計算速度が悪化することが予想される。

何度も固有値問題を解く場合、WORK, RWORK, IWORK はサイズが十分な限り使い回せる。 すなわち、ブロック対角化のように何回か対角化をする場合、最も大きい行列で WORK, RWORK, IWORK を用意しておけば何度も確保し直す必要はない。

計算終了後に INFO に計算の終了状態に関する情報が返される。 INFO = 0 なら正常終了。 INFO < 0 なら INFO 番目の引数に問題があり、異常終了。 INFO > 0 なら計算が収束しなかったことを意味する。

zheevdドライバー

ドキュメント: https://netlib.org/lapack/explore-html/df/d9a/group__complex16_h_eeigen_ga9b3e110476166e66f2f62fa1fba6344a.html

void zheevd_(const char& JOBZ, const char& UPLO, const int& N,
             complex* A, const int& LDA, double* W, complex* WORK,
             const int& LWORK, double* RWORK, const int& LRWORK, int* IWORK,
             const int& LIWORK, int& INFO);

引数 A に対角化する行列を与えると W に与えた配列に計算された固有値が格納される。 配列 A は対角化計算の過程で破壊的変更を受ける。 つまり計算が終了した時点で A は与えた配列と同一の内容ではないので注意。 JOBZ'V' を指定した場合は A の第i列が Wi番目の要素に対応した固有ベクトルになっている。

WORK, RWORK, IWORK は計算のために必要な作業用のメモリ領域であり、LWORKWORK の、LRWORKRWORK の、LIWORKIWORK の配列サイズである。 WORK, RWORK, IWORK の最適なサイズは LWORK, LRWORK, LIWORK のどれかに -1 を与えて zheevd を呼び出すと計算でき、WORK, RWORK, IWORK それぞれの最初の要素に格納される。 つまり、固有値を得るためには 1. LWORK, LRWORK, LIWORK の決定 → 2. 固有値の計算というように2回 zheevd を呼び出す。 それぞれ最適サイズより大きくても問題ない。 最適サイズより小さい場合、計算速度が悪化することが予想される。

何度も固有値問題を解く場合、WORK, RWORK, IWORK はサイズが十分な限り使い回せる。 すなわち、ブロック対角化のように何回か対角化をする場合、最も大きい行列で WORK, RWORK, IWORK を用意しておけば何度も確保し直す必要はない。

計算終了後に INFO に計算の終了状態に関する情報が返される。 INFO = 0 なら正常終了。 INFO < 0 なら INFO 番目の引数に問題があり、異常終了。 INFO > 0 なら計算が収束しなかったことを意味する。

zheevrドライバー

ドキュメント: https://netlib.org/lapack/explore-html/df/d9a/group__complex16_h_eeigen_ga60dd605c63d7183a4c289a4ab3df6df6.html

void zheevr_(const char& JOBZ, const char& RANGE, const char& UPLO, const int& N,
             complex* A, const int& LDA, const double& VL, const double& VU,
             const int& IL, const int& IU, const double& ABSTOL, int& M, double* W,
             complex* Z, const int& LDZ, int* ISUPPZ, complex* WORK,
             const int& LWORK, double* RWORK, const int& LRWORK, int* IWORK,
             const int& LIWORK, int& INFO);

引数 A に対角化する行列を与えると W に与えた配列に計算された固有値が格納される。 配列 A は対角化計算の過程で破壊的変更を受ける。 つまり計算が終了した時点で A は与えた配列と同一の内容ではないので注意。 JOBZ'V' を指定した場合は Z の第i列が Wi番目の要素に対応した固有ベクトルになっている。

zheevx ドライバーは与えられた行列 A の固有値の内の一部のみを計算する機能を持つ。 すべての固有値が必要ではない場合には、計算を打ち切って高速化することができる。 実際に見つかった固有値の個数は M に格納される。

  • RANGE'A' を指定した場合、すべての固有値を計算する。
  • RANGE'V' を指定した場合、(VL, VU] の半開区間に入る固有値のみを求める。
  • RANGE'I' を指定した場合 IL 番目から IU 番目の固有値のみを求める。

ABSTOL は固有値の許容計算誤差に関する引数である。特に理由がなければ DLMCH('S') を与えるのがよい。 v3.10.1時点ではまだそのような実装になっていないが、将来のバージョンではこの設定で最良の精度を得るようになる。

WORK, RWORK, IWORK は計算のために必要な作業用のメモリ領域であり、LWORKWORK の、LRWORKRWORK の、LIWORKIWORK の配列サイズである。 WORK, RWORK, IWORK の最適なサイズは LWORK, LRWORK, LIWORK のどれかに -1 を与えて zheevr を呼び出すと計算でき、WORK, RWORK, IWORK それぞれの最初の要素に格納される。 つまり、固有値を得るためには 1. LWORK, LRWORK, LIWORK の決定 → 2. 固有値の計算というように2回 zheevr を呼び出す。 それぞれ最適サイズより大きくても問題ない。 最適サイズより小さい場合、計算速度が悪化することが予想される。

何度も固有値問題を解く場合、WORK, RWORK, IWORK はサイズが十分な限り使い回せる。 すなわち、ブロック対角化のように何回か対角化をする場合、最も大きい行列で WORK, RWORK, IWORK を用意しておけば何度も確保し直す必要はない。

計算終了後に INFO に計算の終了状態に関する情報が返される。 INFO = 0 なら正常終了。 INFO < 0 なら INFO 番目の引数に問題があり、異常終了。 INFO > 0 なら計算が収束しなかったことを意味する。

コンパイル

Windows

Windows では MSYS2 を使うのが楽。 MSYSの MinGW 64-bit 環境を使う想定で、コンパイルには gcc の c++ コンパイラ、g++ を使う。 LAPACK、REFERENCE BLAS のインストールは以下のコマンドでできる。 REFERENCE BLAS は LAPACK のパッケージに同梱されている。

$ pacman -S mingw-w64-x86_64-lapack

OpenBLAS のインストールは以下のコマンドでできる。 LAPACK のドライバー(後述)も openblas のバッケージに含まれている。

$ pacman -S mingw-w64-x86_64-openblas

REFERENCE BLAS か OpenBLAS かについては基本的に OpenBLAS を使えばよい。 コンパイルオプションとして -lopenblas を指定すると OpenBLAS がリンクされる。 つまり、例えば次のようにすると実行ファイル a.out が生成される。

$ g++ example_zheev.cpp -o a.out -lopenblas

REFERENCE LAPACKが openblas に静的リンクされているみたいなので、-llapack はいらない。むしろ指定順序によってはつけるとLAPACKに静的リンクされている REFERENCE BLAS が優先されてしまうのでつけてはいけない。 あまり、使う理由はないが REFERENCE BLAS を使いたい場合は -llapack を代わりに使う。REFERENCE BLAS が静的リンクされているようなので、-lblas は(MSYSのパッケージを使う場合は)必要ない。

$ g++ example_zheev.cpp -o a.out -llapack

ちなみにコンパイルされた実行ファイルがどちらに依存しているかは ldd コマンドで確認することができる。

$ ldd a.out

Ubuntu

Ubuntu では apt コマンドを使って、REFERENCE BLAS、OpenBLAS をインストールできる。 LAPACK (REFERENCE BLAS) は次のコマンド、

$ apt install liblapack-dev

OpenBLAS は次のコマンドを実行するとよい。

$ apt install libopenblas-dev

また、MKL は Intel 公式の手順 に従うことで容易に使用可能となる。

複数の lapack 互換ライブラリがインストールされている場合、-llapack フラグでリンクされるライブラリを update-alternatives コマンドを使って選択することができる。

$ update-alternatives --get-selections | grep liblapack.so

とすると現在の状態を調べることができる。複数表示される場合は適当なソースを実際にコンパイルして ldd コマンドで確認するのが確実と思われる。これはもっと良い方法がありそうだが…

$ g++ example_zheev.cpp -o a.out -llapack
$ ldd a.out
    (略)
    liblapack.so.3 => /lib/x86_64-linux-gnu/liblapack.so.3 (0x00007fbab32f3000)
    (略)

liblapack.so.3 がリンクされるようである。update-alternatives コマンドで該当する選択肢を探す。

$ update-alternatives --get-selections | grep liblapack.so.3
liblapack.so.3-x86_64-linux-gnu manual   /usr/lib/x86_64-linux-gnu/lapack/liblapack.so.3

この場合、liblapack.so.3-x86_64-linux-gnu が識別子のようである。次のコマンドを実行すると -llapack で使用されるライブラリを選択することができる。

$ sudo update-alternatives --config liblapack.so.3-x86_64-linux-gnu

あるいは -llapack フラグの代わりに -lopenblas-lmkl_rt を使うと必ず OpenBLAS、MKL が使われる。 こちらの方が簡単かもしれない。

  • OpenBLAS を使う例
$ g++ example_zheev.cpp -o a.out -lopenblas

パフォーマンス比較

使用した環境は以下の通り。

  • Ubuntu 20.04
    • gcc 9.4.0 (Ubuntu 9.4.0-1ubuntu1~20.04.1)
    • AMD Ryzen7 3700X (8 core, 16 threads)
    • LAPACK v3.9.0
    • REFERENCE BLAS v3.9.0
    • OpenBlas v0.3.8

1スレッドに制限した場合の結果は以下。

BLASおよびドライバーの比較

使用されているBLASの比較をするとOpenBLASの成績が良い。 MKLも使っては見たものの、AMDのCPUを使っているので最適化度合いはイマイチか。

ドライバー毎の比較をすると zheevr の成績が良い。 zheevxzheevd はほぼ同等。 MKLを除いて zheev は他より2倍以上遅いようである。

さらに OpenBLAS を使用した場合についてマルチスレッドの効果を示したのが次の図である。 zheevd はマルチスレッド化の効果が大きく、4スレッドあたりで zheevrに逆転している。

スレッド数による効果(OpenBLAS)

全体的には zheevdzheevr の成績が良い。 マルチスレッド化の効果が大きければ zheevd を使うのがよいだろう。 ただし、zheevr は求める固有値の数を制限する機能があるなど柔軟なので、問題によってはよいだろう。 どちらにせよ zheevdzheevr の差は小さいのでどちらでも良い気がする。

REFERENCE BLAS/1thread/zheev の場合に比べて OpenBLAS/8thread/zheevd は20倍近い高速化をしている。

CLAPACK と LAPACKE

今回は FORTRAN のサブルーチンを C++ から呼び出す方式をとったが、他のLAPACKを使うためのインターフェースとして CLAPACK と LAPACKE というものがある。

LAPACKはもともと FORTRAN で書かれたプログラムなのだが、CLAPACKはこれを f2c でC言語に自動変換したもの。 正直なところ、普通にCからFORTRANのサブルーチンを呼べるのであまり何が便利なのかわかっていない。

LAPACKE はもともと MKL で使われていた LAPACK をラップしたインターフェースらしい。 現在では本家LAPACKに逆輸入されて公式に同梱・サポートされている。 行列の引数に対しては Column/Row major を指定することができ、必要に応じて再配置してくれるようである。 lapacke_{ドライバー名}lapacke_{ドライバー名}_workの2つの系統の関数が提供されており、前者は作業用のメモリ領域を自動で確保してくれるので便利。 後者は Column/Row major の指定以外はLAPACKのネイティブインターフェースを踏襲している。 lapacke_{ドライバー名}は便利であるが、究極的に速度を求める場合は結局、一度確保した作業用メモリを使いまわしたりする必要があるので結局、lapacke_{ドライバー名}_work が必要になるのだと思われる。

Vim script でレーベンシュタイン距離を計算する

Vim script にビットシフト演算子が導入されたそうです。

というわけで、とりあえずレーベンシュタイン距離を計算してみました。 レーベンシュタイン距離というのは編集距離と呼ばれる文字列同士の類似度の尺度の一つですが、距離というだけあって小さいほど似ている文字列ということになります。

詳しくは wikipedia とかみてください。

レーベンシュタイン距離 - Wikipedia

基本的には動的計画法 (Dynamic programming) を用いて計算するのが直感的にはわかりやすいのですが、その変形で bit-parallel アルゴリズムと呼ばれる方法もあります。 これは動的計画法で埋めていくマトリックスの列の計算に相当する部分を、ビット演算で置き換えてしまうという方法です。 はい、冒頭のビットシフトはここに繋がります。 Bit-parallel法の計算にはちょっとだけビット左シフトが使われるのです。 Bit-parallel法を使うと、いくらかの制限は付きますが動的計画法に比べて高速に計算することができます。

というわけで Vim script で実装して比較してみました。

(6/18 Vim9 script の結果を追記)

Dynamic programming Bit-parallel Bit-parallel (obsolete) Dynamic programming (vim9) Bit-parallel (vim9)
min. 0.045096 0.002326 0.002409 0.003242 0.000235
max. 0.053966 0.002536 0.002647 0.003401 0.000254
mean 0.046023 0.002372 0.002452 0.003330 0.000245
median 0.045995 (1x) 0.002372 (19.4x) 0.002451 (18.8x) 0.003334 (13.8x) 0.000245 (187.7x)

ランダムな64文字の文字列同士を1000回計算して、一回の計算にかかった時間の最短(min.)、最長(max.)、平均値(mean)、中央値(median)を表にしました。 単位は秒です。 中央値の括弧のなかみは動的計画法より何倍高速かを表示しています。 Bit-parallel (obsolete) はビット左シフト演算 (n << m) を積と累乗 (n * 2m) で置き換えた場合の結果です。

まあ動的計画法よりは早いですけどシフト演算ない場合との比較は残念な感じですね…。 そもそも、bit-parallelではシフト演算が大体 n << 1 みたいなのばっかりなのでありがたみが薄かった…。 そして Vim9 script 速い...!

一応、成果物です。

github.com

シフト演算子とは全然関係ない話なんですけど、Vim script の min(), max() 関数が浮動小数点数に対応してないのさっきまで知らなかった…

Vim のモーションについて

Vim でカーソルを動かすためのコマンドの一部はモーションと呼ばれます。 これについての基本的な説明と組み込みのモーションコマンドについてはヘルプを読む(:help motion.txt)のが一番なので、この記事ではモーションとはなにかを簡単に解説した後、ユーザー定義モーションの書き方などを説明します。

モーションとは

厳密な定義があるかはよくわかりませんが、私個人としてはモーションコマンドというと以下のような機能を備えていることを期待します。

  • ノーマルモードにてカーソルを移動させる。
  • ヴィジュアルモードにてカーソルを移動させ、ヴィジュアル選択範囲を変更する
  • オペレータ待機モードにおいてカーソル移動の軌跡をオペレータの作用対象とする
  • タブ、ウィンドウ、バッファは移動しない
  • 可能であれば [count] 指定を受け付ける

ひとまず、この記事では上のような機能を備えたものをモーションと呼ぶことにします。また、ヘルプの motion.txt においてはテキストオブジェクト(:help text-objects)についても書かれていますが、これは分けて考えることにしてこの記事ではあまり言及しないことにします。

組み込みのモーションコマンドはかなり種類があり、例えば文字単位でカーソルを動かす h, j, k, l や単語単位でカーソルを動かす w, b, e, ge、行頭・行末へカーソルを動かす 0, ^, $ がモーションというのは理解しやすいかと思いますが、カーソル下の単語を検索して移動する *, #、マークによる移動 ', `、さらには検索コマンド /, ? や ex コマンド : もカーソルが動く限りはモーションです。このように Vim の豊富なカーソル移動手段は実はほとんどすべてモーションとして働きます。

ノーマルモードにおけるモーション

ノーマルモードにおけるカーソル移動は、おそらく最も理解しやすいモーションの機能かと思います。

多くのモーションコマンドは [count] の入力を受け付けており、これを組み合わせることでさらに便利になります。 [count] の解釈はコマンドごとに違っており、例えば 3l と入力すると3文字分右へカーソルを動かしますが、 3G と入力するとバッファの3行目に移動します。 それぞれのコマンドが [count] をどのように解釈するかはヘルプを確認すると良いでしょう。

もう一つ留意する点としてはカーソルが大きく移動することがありうるようなコマンドは jumplist を更新する点です。(:help jump-motions) カーソルの大きな移動を CTRL-O および CTRL-I でたどることができる便利な機能です。 Vim のヘルプを読むときなどにはタグジャンプをした後にもとの位置に戻れたりするのでよく使いますね。 ちなみに CTRL-O および CTRL-I はこの記事でいうモーションコマンドではありません。 これらのコマンドはヴィジュアルモードやオペレータ待機モードでは機能しません。

ヴィジュアルモードにおけるモーション

基本的にはノーマルモードにおける機能と同じですが、ヴィジュアル選択の一端を移動させて範囲を変更します。

ヴィジュアルモードには文字単位、行単位、矩形ヴィジュアルモードの3種類が存在していますが、 矩形ヴィジュアルモードにおける $ コマンドは少し特殊で、選択している行のうち最も長い行にあわせて選択範囲を拡張します。(:help v_$)

オペレータ待機モードにおけるモーション

上2つに比べて理解が少し難しいのがオペレータ待機モードにおけるモーションの機能だと思います。 オペレータ待機モードとは、オペレータコマンド (:help operator) を使用したあと続けて入力待ちになっている状態です。 オペレータコマンドは例を挙げると y, d, c などのことで、例えばノーマルモードで d を押した後さらに入力を待っている状態がオペレータ待機モードです。 ここでモーションコマンドかテキストオブジェクトを入力することでオペレータが作用する範囲を決定します。 オペレータの作用範囲は2つの位置で指定できる範囲です。 モーションの場合はカーソルの初期位置と移動後の位置の2点がこれに当たるので、例えば d$ とするとカーソル位置から行末までを削除します。

さらにモーションによるオペレータの作用範囲の決定にはもう少し細かいルールがあります。 これはカーソルが同じ位置まで移動した場合でも、編集の対象となる範囲が違う場合があるということを指します。 例を挙げると、行が十分短く折り返し (:help 'wrap') が存在しない場合、jgj は同じ場所へカーソルを移動しますが djdgj の結果は違います。 このように作用範囲の形を決めるルールがあり、それらはそれぞれ文字単位/行単位/矩形と呼ばれます。 さらに文字単位で作用するモーションにはその作用範囲が排他的なものと内包的なものが存在します。

  • 文字単位 (characterwise)
    • 排他的 (exclusive)
    • 内包的 (inclusive)
  • 行単位 (linewise)
  • 矩形 (blockwise)

さて、たくさんの用語が出てきましたが、それぞれどのような意味かをカーソル位置と作用範囲を図示して解説します。 以下のようなバッファにてカーソルがBからCおよびKへ移動した場合のオペレータ作用範囲を赤く示します。

ABCD
EFGH
IJKL

文字単位(排他的)

characterwise(exclusive)

モーションによりカーソルが移動した場合にカーソルの初期位置から終了位置までの範囲をオペレータの作用範囲とします、ただし移動終了後のカーソル直下の文字は範囲に含まれません。 dl で一文字しか消えないのは l が排他的文字単位だからですね。

注意する点としては、内包的なモーションで右へ移動した場合も左へ移動した場合も範囲に含まれないのは常に右端1文字です。

文字単位(内包的)

characterwise(inclusive)

文字単位(内包的)な場合と似ていますが、内包的なモーションと違い範囲の右端1文字も含まれます。

行単位

linewise

文字単位の場合とは違い、カーソルの桁位置とは関係なくモーションで「通過」する行全体を作用範囲に取ります。

矩形

blockwise

カーソル移動の初期位置と終端位置を左上隅および右下隅(あるいは右上隅および左下隅)に持つ矩形の範囲を作用範囲に取ります。 デフォルトで矩形のモーションは存在しませんが、オペレータ待機モードで CTRL-V を押すことで強制的に矩形のモーションにすることができます。(:help o_CTRL-V)

ルールの切り替え

モーションごとにどのように作用範囲が決まるかが決まっており、ヘルプに書いてあります。 例えば、 j は行単位なモーションです。 以下にヘルプを引用します。

[count] 行下に移動(行単位|linewise|)

ちなみに組み込みのモーションには文字単位か行単位のもののみ存在します。 ただし、モーションを入力する前に v, V, CTRL-V を入力することで強制的に規則を変更することができます。 つまり、dvj は文字単位(排他的)で文字列を削除します。 なお、複数入力しても最後に入力したもののみ有効です。

  • v : 文字列単位のモーションの排他的/内包的を切り替える。行単位及び矩形のモーションは文字単位排他的にする。 (:help o_v)
  • V : モーションを行単位にする (:help o_V)
  • CTRL-V : モーションを矩形にする (:help o_CTRL-V)

カーソルがゼロ幅だとしていたら排他的/内包的は必要なかったような気もしますが…

ユーザー定義モーションを書く

組み込みにもたくさんの便利なモーションが実装されていますが、ユーザーが自らモーションを作ることもできます。 ノーマルモード、ビジュアルモードについてはもちろんですがオペレータ待機モードにおいても同じくカーソルを動かすだけでよいです。 カーソルの初期位置と移動後の位置に応じてオペレータの作用範囲が決定されます。

ユーザー定義モーションを書く場合、Ex コマンド : を使ってカーソルを移動するのが便利です。 Exコマンドを使ったカーソルの移動はオペレータ待機モードでは常に排他的文字単位に動作するので、例えば

d:call cursor(line('.'), col('.')+1)<CR>

と入力すると dl のように振る舞います。 この場合、

:call cursor(line('.'), col('.')+1)<CR>

が一つのモーションになります。 このように Ex コマンドをモーションとした場合、エンターキー <CR> を押した瞬間にモーションとして確定し、カーソルの移動先に応じて削除されます。

複数のモーションを組み合わせる

まずは簡単なものから始めましょう。 例えば csv ファイルなどを開いた場合に最初から3つ目の要素の最初の文字にカーソルを動かすモーションを作ってみます。 カーソルがどこにあるかわからないので、まず最初に行頭に移動して2つ目のコンマに移動し、その1文字右側にカーソルを動かすことにします。 簡単には以下のように書けますが、これはあまりよくない例です。

noremap <Space> 02f,l

どこが問題なのでしょう?

まずノーマルモードとヴィジュアルモードにおいては誤ってカウントが与えられた場合に問題が起こります。 すなわち、2<space> とユーザーが入力した場合、202f,l と入力するのと同じなので、202個目のコンマまで移動(202f,)してしまいます。 また、オペレータ待機モードでは全く期待したように動きません。 つまり d<Space> と入力した場合、d02f,l と入力するのと同じなので、まず d0 が実行され行頭までが削除されてから、2f,l が実行されます。 オペレータ待機モードでもカーソル位置から3つ目の要素の前までを削除するようにするには Ex コマンドとして1つのモーションにまとめましょう。

nnoremap <Space> :<C-u>normal! 02f,l<CR>
xnoremap <Space> :<C-u>normal! gv02f,l<CR>
onoremap <Space> :normal! 02f,l<CR>

<C-u>:normal コマンドに余計な {range} 指定が入ることを防ぎます。 例えば 2: などと入力するとコマンドラインが :.,.+1 となるように {range} が自動入力されているのが確認できます。 またヴィジュアルモードから Ex モードに入るとコマンドラインが :'<,'> となります。 このように {range} の自動入力が問題を起こさないために必要になります。

ヴィジュアルモードのマッピングのみ少し違うのはヴィジュアルモードで Ex コマンドを使った場合、ノーマルモードに戻ってしまうためです。 これを避けるため gv コマンドでヴィジュアルモードに入りなおしています。 このような手間は <Cmd> (:help :map-cmd) の使用によって将来は必要なくなるかと思われますが、いまのところは <Cmd> が使えるバージョンの Vim が行き渡っているとは言い難いので未来に期待します。

オペレータ待機モードでも 02f,l という複数のモーションではなく :normal! 02f,l<CR> という一つのモーションが実行されます。 何度かカーソルを動かしているようにも見えますが、Ex コマンドとして一つのモーションなので、カーソルの初期位置と :nomal! コマンドの終了時のカーソル位置のみが重要となります。 なお、こうして作ったモーションはオペレータと組み合わせて使うことで、すでにドットリピートが可能です(!)。

ユーザー定義関数を使う

ひとまず機能を完成させましたが、どうせならもう少し気の利いたマッピングを作ってみましょう。 次はカウントの指定を受けつけて、[count] 個目の最初の非空白文字にカーソルを移動するように改良してみましょう。 実現したい機能が複雑化して行くと単純なノーマルモードコマンドの組み合わせでは物足りなくなります。 このような場合には関数を定義してマッピングに使用しましょう。

function! s:my_motion(mode) abort
  let l:count = v:count1
  let l:line = getline('.')

  " 必要ならヴィジュアルモードに入り直す
  if a:mode is# 'x'
    normal! gv
  endif

  " n 個目の要素がなければ終了
  if count(l:line, ',') < l:count - 1
    return
  endif

  " まず行頭に移動する
  normal! 0

  " (n - 1) 個目のコンマへ移動する
  if l:count > 1
    execute 'normal! ' . (l:count - 1) . 'f,'
  endif

  " 最初の非空白文字へ移動する
  call search('\S', '', line('.'))
endfunction

nnoremap <silent> <space> :<C-u>call <SID>my_motion('n')<CR>
xnoremap <silent> <space> :<C-u>call <SID>my_motion('x')<CR>
onoremap <silent> <space> :call <SID>my_motion('o')<CR>

キーマッピングがどのモードで使われたかを確実に認識するために引数で与えることにしています。 関数 s:my_motion() の中で mode() 関数を使っても同じことはできません。 なぜなら関数が呼ばれたときにはすでにノーマルモードに遷移しているためです。 ただし、上の <Cmd><expr> を使った場合には mode() も機能するでしょう。

ところでこのマッピングは csv ファイルに文字列としてコンマが含まれていたら正しく動きませんね!

オペレータ待機モードでの挙動の制御

上にも書いた通り Ex コマンドをモーションとした場合、オペレータ待機モードでは必ず排他的文字単位の挙動になります。 ではこの挙動を変えたい場合はどうしたらいいでしょう? 例として組み込みの f コマンドを模倣しながら考えてみましょう。 f コマンドはユーザーの入力1文字を受けつけて、その文字を行内から探して移動するコマンドです。 ひとまずその機能を作りましょう。

ここでは入力のフィルタリングやマルチバイト対応に関しては本題ではないので大目に見ることにします。 また、とりあえずオペレータ待機モードのみ考えます。

  • 例1
function! s:my_f() abort
  let l:count = v:count1
  let l:c = nr2char(getchar())
  if l:c is# "\<Esc>"
    return
  endif

  let l:c_regex = '\C' . escape(l:c, '~"\.^$[]*')
  let l:idx = match(getline('.'), l:c_regex, col('.'), l:count)
  if l:idx < 0
    return
  endif

  call cursor(line('.'), l:idx + 1)
endfunction

onoremap <silent> <space>f :call <SID>my_f()<CR>

余談ですが、v:count1 は関数内でも :normal コマンドの使用などで容赦なく変更されるので、余計な心配を抱えないために関数のできるだけ先頭で退避させておくのがおすすめです。

さて、df{なにか適当な文字} などして使ってみるとなんとなく動くようですが、組み込みの f コマンドとは挙動が違いますね。 組み込みの f コマンドは内包的文字単位で動作するのに対し、上に書いた模倣版は排他的文字単位で動作するため、最後の一文字の扱いが違います。 具体的には abcde という文字列の a から c まで移動した場合(すなわち dfc あるいは d<space>fc)、組み込みの方は de となるのに対し模倣版は cde となります。

ではどうしたらよいでしょうか? いくつか実装をみながら考えてみましょう。

o_v を使ってみる:よくない例

まず簡単に思いつくのは上で紹介した o_v を使ってマッピングを以下のように定義することだと思います。

  • 例2
onoremap <silent> <space>f v:call <SID>my_f()<CR>

: の前の v に注目です。 これは一見意図したとおりに動くように見えますが、残念ながら重大な問題があります。 実はこのモーションは失敗できません、つまり指定した文字が見つかっても見つからなくても、あるいは <Esc> を押してさえ必ず1文字は作用範囲としてしまって「なにもせず終了する」ことができません。 たとえカーソルが動かなくともモーションを内包的文字単位や矩形単位にした場合はカーソル下の一文字を、行単位にした場合はカーソルのある一行を編集してしまうことになります。

実は、オペレータ待機モードにおいて「なにもせず終了する」ことができるのは排他的文字単位の場合のみです。 (上にある図のBからBへ移動した場合を考えてみるとよいでしょう。) f コマンドは指定した文字が見つからなかった場合は何もせず終了するのが期待する動作なのでこれではだめです。

さらに <expr> も使ってみる:あまりよくない例

o_v を直接使うと失敗できないマッピングができてしまいました。 では、<expr> をつかってワンクッション挟んでみましょう。

  • 例3
function! s:my_f_expr() abort
  let l:count = v:count1
  let l:c = nr2char(getchar())
  if l:c is# "\<Esc>"
    return "\<Esc>"
  endif

  let l:cursor_right = getline('.')[col('.') :]
  if count(l:cursor_right, l:c) < l:count
    return "\<Esc>"
  endif

  return printf("v:call My_f('%s', %d)\<CR>", l:c, l:count)
endfunction

function! My_f(c, count) abort
  let l:c_regex = '\C' . escape(a:c, '~"\.^$[]*')
  let l:idx = match(getline('.'), l:c_regex, col('.'), a:count)
  call cursor(line('.'), l:idx + 1)
endfunction

onoremap <silent><expr> <space>f <SID>my_f_expr()

こうすると<Esc> を押す、あるいは指定した文字が見つからない場合には何もせずに終了します。 ですが残念ながら、このマッピングはドットリピートした場合に o_v を直接つかった上の例と同じ問題を抱えます。 実は <expr> 属性がついたマッピングはドットリピート時に再評価されません。 <space>f を使った場合には s:my_f_expr() が評価され、その返り値をキーシークエンスとして実行しますが、 ドットリピート時には再評価されず、前回の評価値を使いまわします。 つまり、通常のマッピングでは

  • s:my_f_expr() が評価される → 評価値である v:call My_f({c}, {count})<CR> が実行される

の順に実行され、ドットリピート時には

  • v:call My_f({c}, {count})<CR> が実行される

となります。 つまり、ドットリピート時に実行されるのは前回の評価値 v:call My_f({c}, {count})<CR> というわけです。 これではドットリピートで探したい文字が見つからなかった場合に、意図せずカーソル下の文字を編集してしまいます。

実はこの実装にはドットリピート時に getchar() が呼ばれないというメリットがあるのですが、解説は後の章に譲ります。

単純に1文字分範囲を増やす:まあまあよい例

o_v を使うのはひとまずやめることにしてみます。 排他的文字単位の範囲と内包的文字単位の範囲を比べると、範囲の右端1文字分だけが違います。 なので、難しいことを考えることはやめて1文字分範囲を広げましょう。

  • 例4
function! s:my_f() abort
  let l:count = v:count1
  let l:c = nr2char(getchar())
  if l:c is# "\<Esc>"
    return
  endif

  let l:c_regex = '\C' . escape(l:c, '~"\.^$[]*')
  let l:idx = match(getline('.'), l:c_regex, col('.'), l:count)
  if l:idx < 0
    return
  endif

  call cursor(line('.'), l:idx + 1)

  " 1文字分余計に移動する
  normal! l
endfunction

onoremap <silent> <space>f :call <SID>my_f()<CR>

簡単ですね。 ただし内包的文字単位のモーションを作る場合のみ有効な手段で、行単位や矩形への応用は利かないのが課題として残ります。

ビジュアル選択を使う:よい例

前の章にて、オペレータ待機モードでカーソルを移動させれば初期位置・終了位置でオペレータの作用範囲を決定する、と書きましたが実はこれは完全な説明ではありません。 移動のみでなくテキストをビジュアル選択した場合はカーソル移動よりも優先してその選択範囲がオペレータの作用範囲になります。 すなわち、次の優先順位で決定されます。

  1. テキストがビジュアル選択されていれば選択範囲がオペレータの作用範囲となる
  2. ビジュアル選択されていなければ、カーソルの初期位置および最終位置によってオペレータの作用範囲を決定する

なので、内包的文字単位にしたい場合は文字単位選択(:help characterwise-visual)を、行単位にしたい場合は行単位選択(:help linewise-visual)を、矩形にしたい場合は矩形選択(:help blockwise-visual)を使えばよいのです。

  • 例5
function! s:my_f() abort
  let l:count = v:count1
  let l:c = nr2char(getchar())
  if l:c is# "\<Esc>"
    return
  endif

  let l:c_regex = '\C' . escape(l:c, '~"\.^$[]*')
  let l:idx = match(getline('.'), l:c_regex, col('.'), l:count)
  if l:idx < 0
    return
  endif

  " 内包的文字単位にするためには 'selection' オプションが inclusive である
  " 必要がある
  let l:selection = &selection
  set selection=inclusive
  try
    normal! v
    call cursor(line('.'), l:idx + 1)
  finally
    let &selection = l:selection
  endtry
endfunction

onoremap <silent> <space>f :call <SID>my_f()<CR>

この方法は正しく失敗するモーションを定義できるうえ、かつ比較的簡単に内包的文字単位、行単位、矩形のモーションを作ることができます。 気にかかる点といえば '<'> マークを否応なく更新してしまう点ですが、実用上ほとんど問題にならないと思います。

また、このオペレータ待機モードでビジュアル選択を使うという方法は実はテキストオブジェクトを作る方法でもあります。 オペレータ作用範囲の一端がカーソルの初期位置に縛られるモーションに対して、テキストオブジェクトにはこの制限がありません。 これはビジュアル選択によって任意の範囲を指定できることに対応しています。

ドットリピート時の挙動の制御

さて、上の模倣版の f コマンドですが、まだ挙動のおかしな点があります。 ドットコマンドによって編集を繰り返した場合の動作が違うのです。

組み込みの f コマンドであれば、例えば dfa と入力した場合は文字 a までを削除し、更に . を押すと「a まで削除」を繰り返します。 この挙動はよくよく考えると、通常のマッピングとドットリピートで異なる挙動をしていると言えますね。 つまり、通常のマッピングではユーザーの入力を促しますが、ドットリピートの場合では入力を待たずに以前の入力を使いまわします。 しかし、上の模倣版の f コマンドはドットリピートの際にもモーション :call <SID>my_f()<CR> を繰り返すので、毎度 getchar() 関数を呼び、入力を待ってしまいます。

このようにドットリピートの場合に処理を分けるにはどうしたらいいでしょうか?

これにはすでに出た <expr> を使うことができます。先にも述べたとおり、<expr> マッピングの式はドットリピート時に再評価されません。 この性質を利用することでドットリピート時の挙動を分けることができます。

  • 例6
function! s:my_f_expr() abort
  let l:count = v:count1
  let l:c = nr2char(getchar())
  if l:c is# "\<Esc>"
    return "\<Esc>"
  endif

  let l:cursor_right = getline('.')[col('.') :]
  if count(l:cursor_right, l:c) < l:count
    return "\<Esc>"
  endif

  return printf(":call My_f('%s', %d)\<CR>", l:c, l:count)
endfunction

function! My_f(c, count) abort
  let l:c_regex = '\C' . escape(a:c, '~"\.^$[]*')
  let l:idx = match(getline('.'), l:c_regex, col('.'), a:count)

  let l:selection = &selection
  set selection=inclusive
  try
    normal! v
    call cursor(line('.'), l:idx + 1)
  finally
    let &selection = l:selection
  endtry
endfunction

onoremap <silent><expr> <space>f <SID>my_f_expr()

あるいは陽にドットリピートであることで挙動を分けることもできますね。

  • 例7
let s:FALSE = 0
let s:TRUE = 1

let s:dotrepeat = s:TRUE
let s:c = ''

function! s:my_f_expr() abort
  let s:dotrepeat = s:FALSE
  return ":call My_f()\<CR>"
endfunction

function! My_f() abort
  let l:count = v:count1
  if !s:dotrepeat
    let s:c = nr2char(getchar())
  endif

  if s:c is# "\<Esc>"
    return
  endif

  let l:c_regex = '\C' . escape(s:c, '~"\.^$[]*')
  let l:idx = match(getline('.'), l:c_regex, col('.'), l:count)
  if l:idx < 0
    return
  endif

  let l:selection = &selection
  set selection=inclusive
  try
    normal! v
    call cursor(line('.'), l:idx + 1)
  finally
    let &selection = l:selection
  endtry
  let s:dotrepeat = s:TRUE
endfunction

onoremap <silent><expr> <space>f <SID>my_f_expr()

余談:マクロ時の挙動

本当は模倣版の f コマンドには依然、不満点が残っています。 なぜなら、組み込みの f コマンドとはマクロ (:help complex-repeat) で使った時の挙動が異なるためです。 組み込みの f コマンドは指定した文字が見つからず、移動に失敗した場合にはマクロの実行を停止しますが、模倣版の f コマンドはしません。 残念ながら、ユーザー定義モーションが明示的にマクロを停止する手段はいまのところ知らないので、ご存知の方がいたら教えてください。

まとめ

  • モーションはノーマルモード、ヴィジュアルモード、オペレータ待機モードでカーソルを動かす機能
  • オペレータ待機モードでの挙動は文字単位/行単位/矩形のどれかに分類され、文字単位はさらに排他的/内包的の二種がある
  • ユーザー定義モーションを書く場合、Exコマンドとして実装するのが便利
    • :normal コマンドや :call コマンドに {range} が渡らないよう注意
    • <Cmd> が早く安心して使えるようになりますように
  • 内包的文字単位/行単位/矩形のモーションを作りたい場合は、ビジュアル選択を使うのがよさそう(?)
  • ドットリピート時の挙動を通常のマッピングと分けるためには <expr> マッピングを使う
  • ユーザー定義モーションがマクロを停止する手段をご存知の方は教えてください

おまけ

上の成果をプラグインにまとめました。

GitHub - machakann/vim-fim: "f" imitated; not improved.

次のように使うことができます。実用性はないかと思いますが。

map <space>f <Plug>(fim)

<Plug>(fim-trial01) から <Plug>(fim-trial07) までが上に示されたコードを使ったマッピングです。<Plug>(fim) はすべての成果をまとめたうえで、さらにノーマルモード、ヴィジュアルモード、オペレータ待機モードの三つのモードをサポートします。

羽田空港泊

2020/4/12 11:00(UTC+0800) @台北の寮

前の職場の寮の部屋をでる。二つ隣の部屋に住んでいるインド人の同僚が荷物を運ぶのを手伝ってくれる。やさしい。奥さんと幸せにね。

もともと、電車で移動の予定だったんだけど、雨降り&荷物が思った以上に重いので急遽タクシーを呼んだ。

2020/4/12 11:30(UTC+0800) @台北松山空港

タクシーは相変わらずめちゃくちゃ飛ばす。早めにでたのも相まって時間に余裕がありすぎる。タクシーにした時点で時間をずらしてもよかった。

待つ間、放置している Github の issue をみたり、見なかったことにしたりする。ハヤカワの電子書籍セールあしたまでやんけ!

SUBWAY でツナサンド (6in.) を食べる。おいしい。

2020/4/12 12:10(UTC+0800)ぐらい?(うろ覚え) @台北松山空港

ツナサンド食ってたら JL098 便の搭乗手続き開始のアナウンスがなる。急がずに優雅にリンゴジュースを飲み干しげっぷ。

手続きの時に係員のお姉さんに「しってる?公共交通機関とかあっちで乗っちゃだめだよ?」とか言われる。ここぞとばかりに「レンタカーを予約してる」とドヤ顔を決める。「準備がいいね!」とか言われてまあまあ有頂天。後から思えば死亡フラグ。

2020/4/12 14:20(UTC+0800) @台北松山空港

時間通りに搭乗開始。人少ないのであっという間に終わり。

周りの席に全く人がいない。

飛行機は驚くぐらいすぐ動き始める。飛行機ってこんなスムーズな乗り物だったっけ。

窓から台北101と圓山大飯店がみえて切ない。台湾には結構長くいたな…とセンチメンタル。

いや?、滞在期間はとにかく台北101と圓山大飯店は片手でも余るぐらいしか行ってなかったわ。

離れる前に行きたいご飯屋さんいくつかあったけど、ダメだったなー。

中華料理は4,5人集めないと食べられない逸品が結構ある。

金曜日に(一人で)小籠包を刻み生姜と一緒に腹いっぱい食べたのでとりあえず良しとする。

またこよ。

2020/4/12 15:00(UTC+0800)ぐらい?(うろ覚え) @JL098 便機内

JAL の機内食うまうま。そぼろご飯と鳥の照り焼き、そば、アブラナっぽい謎野菜のおひたしと果物、プリン。おいしい。

台湾・日本の行き来ぐらいだと機内食ないほうが嬉しい、現地で腹減ってるぐらいがベスト、と思ってたけど今回ばかりはナイスだったと後で思う。

2020/4/12 17:45(UTC+0900) @JL098 便機内(羽田空港)

羽田空港着。はやい。予定では 18:30 着なので45分も早く着いたとゴキゲン。つまり死亡フラグ。

2020/4/12 18:00(UTC+0900) @羽田空港の搭乗待機ロビー1

誘導の人たちに導かれて多分待機ロビーの一つへ。なんとか法が云々でPCR検査を受けることが説明される。

なんかこの辺写真とか動画とかとるなって言われたから詳しく書かない。

しかし、どうやら同便の搭乗者は見た感じ八人だったみたいだ。

2020/4/12 18:20(UTC+0900) ぐらい?(うろ覚え) @搭乗待機ロビー間の廊下

靴まで不織布っぽいので覆った医療従事者が、私の鼻に長い綿棒みたいなの突っ込んで鼻の奥の粘膜にタッチ&ゴー。

子供のころやったなー、と懐かしくなる。あのとき多分泣いた。

気持ちいいもんではないが、正直に言えば注射とかじゃなくてよかった。

血液検査だったら、今でも泣いたかもせん。

2020/4/12 18:25(UTC+0900) ぐらい?(うろ覚え) @搭乗待機ロビー2

検査の結果がでるまで一日弱ぐらいかかることを教えられる。まじかよ。

検査の結果がでるまで入国できないらしい。まじかよ。

しゃーないので、予約していたホテルとレンタカーはキャンセルした。すまぬ。

バスで送ってくれるらしいし、幸い私の新しい自宅方面にも行くらしい。ある程度降りる場所の融通も利くみたいだが大丈夫なんだろうか。

そういや、よく考えたら二週間以内に転入手続きって無理やん。ただの引っ越しが難しすぎる。

2020/4/12 19:10(UTC+0900)

なぜかまた別の待機ロビーへ移動。

毛布が借りられるらしいので一つ借りた。ありがたい。しかし、歴史的なあれこれのせいでイメージ悪いなあ。消毒してあるとは思うけど。

2020/4/12 19:30(UTC+0900) @搭乗待機ロビー3

のり弁もらった!コロッケにちょっとソース垂らして食べる。おいしい。

2020/4/12 19:48(UTC+0900) @搭乗待機ロビー3

イスで寝る話を聞いた姪が私を心配しているらしい。やさしい。落ちないでね、だって。やさしい。

2020/4/12 22:23(UTC+0900) @搭乗待機ロビー3

たまーにどこかから咳の音が聞こえる。

しょうがないし、もちろん防疫に協力したいと思うがちょっと怖い。

空港の職員さんとかも、親切にしてくれたけど不安だろうなー。

それはそれとしておなかすいた。

2020/4/12 22:38(UTC+0900) @搭乗待機ロビー3

どこかで子供が泣いてえずいてる。悲壮感がある。かなしい。

2020/4/12 23:10(UTC+0900) @搭乗待機ロビー3

結果が出た。陰性ほっとする。