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文字は作用範囲としてしまって「なにもせず終了する」ことができません。 たとえカーソルが動かなくともモーションを内包的文字単位や矩形単位にした場合はカーソル下の一文字を、行単位にした場合はカーソルのある一行を編集してしまうことになります。

実は、オペレータ待機モードにおいて「なにもせず終了する」ことができるのは排他的文字単位の場合のみです。 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

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

Julia 言語で汎用一次元数値積分関数を書きました

二重指数関数型数値積分公式 を使った、非適応型の数値積分プログラムです。

GitHub - machakann/DoubleExponentialFormulas.jl: One-dimensional numerical integration using the double exponential formula

高橋・森によって提案された変数変換を施すことによって、被積分関数を積分区間の両端で急激に減衰する新しい関数に変換します。このような関数は基本的に元の関数よりも数値積分が容易で、主に台形則などを使って高速かつ精度よく近似解を得ることができます。

被積分関数が積分区間全体にわたってなめらかに変化する場合に、特に少ない評価点数で高精度な値を返します。また、もう一つの特徴として端点での特異性に非常に強い点が挙げられます。

Tanh-sinh quadrature

積分区間や被積分関数の振る舞いによって多くの変形がありますが、最も汎用的かつ基本的な三種を実装しました。tanh-sinh quadrature はその一つで、任意の被積分関数の [-1, 1] の区間での数値積分を求めます。

\begin{align} \int_{-1}^{1} f(x) dx = \int_{-\infty}^{\infty} f\left(x(t)\right) \frac{dx}{dt} dt = \int_{-\infty}^{\infty} f\left(x(t)\right)w(t) dt \end{align}

 x についての被積分関数  f(x) を、  t についての新しい被積分関数  f(x(t))w(t) へ変換し、それに伴い積分区間をうつしています。ここで、 x(t) および  w(t) は以下のような関数です。

\begin{eqnarray} x\left(t\right) = \sinh \left( \frac{π}{2} \sinh t \right) \\ w\left(t\right) = \frac{π}{2} \cosh t \cosh\left(\frac{π}{2} \sinh t \right) \end{eqnarray}

この変数変換が肝なので、以下の積分を例にとり被積分関数が実際にどのように変化するかを見てみましょう。

\begin{align} \int_{-1}^{1} \frac{dx}{\left(2-x\right)\left(1-x\right)^{1/4}\left(1+x\right)^{3/4}} \end{align}

この関数は積分区間の両端  x = \pm1 で急速に発散しており、数値積分を難しくしています。しかし、変数変換を施したあとの関数  f(x(t))w(t) は正負の両方向で急速に減衰する関数に変換されていることがわかります。

fig.1

元の関数の区間両端での発散が解消された代わりに両無限区間 [-∞, ∞] での積分になっています。しかし、変換後の被積分関数は正負の両方向へ向かうにつれ急激に減衰する関数となっているため、台形則の適用により無限和に近似し  f(x_k)w_k が十分小さくなった地点で打ち切ることができます。

\begin{align} \int_{-\infty}^{\infty} f\left(x(t)\right)w(t) dt \approx h\sum_{k = -\infty}^{\infty} f(x_k)w_k \end{align}

ここで  x_k および  w_k は台形則における刻み幅  h を用いて次のように表せます。

\begin{eqnarray} x_k = x(kh) \\ w_k = w(kh) \end{eqnarray}

 x_k および  w_k は被積分関数に依存しないため、刻み幅  h を決めておけば事前に  x_k,  w_k のテーブルを計算しておくことができます。これも速度が求められる場面では重要な特性といえるでしょう。また事前に計算しない場合でも、tanh-sinh quadrature については  x(t) が奇関数、 w(t) が偶関数なので、 t = 0 に対する対称性 ( x(t) = -x(-t), w(t) = w(-t)) を利用して計算量を減らすことが可能です。

さらに別の変数変換を適用することで任意の有限区間 [a, b] での積分をすべてカバーします。

\begin{align} \int_{a}^{b} f(x) dx = \frac{b - a}{2} \int_{-1}^{1} f\left(x(u)\right) du = \frac{b - a}{2} \int_{-\infty}^{\infty} f\left(x\left(u(t)\right)\right)w(t) dt \end{align}

\begin{align} where\ \ x(u) = \frac{b + a}{2} + \frac{b - a}{2}u \end{align}

DoubleExponentialFormulas.jl はほかに exp-sinh quadrature, sinh-sinh quadrature を組み合わせて任意の有限区間 [a, b]、片無限区間 [a, ∞] [-∞, b]、両無限区間 [-∞, ∞] に対応しています。

使い方

関数 quaddeFloat64 の精度で数値積分を計算します。

    I, E = quadde(f::Function, a::Real, b::Real, c::Real...;
                  atol::Real=zero(Float64),
                  rtol::Real=atol>0 ? zero(Float64) : sqrt(eps(Float64)))

I は得られた積分値、E は推定誤差です。たとえば、f(x) = 1/(1 + x²) の区間 [-1, 1] での積分は次のように計算します。

using DoubleExponentialFormulas
using LinearAlgebra: norm

f(x) = 1/(1 + x^2)
I, E = quadde(f, -1.0, 1.0)

I ≈ π/2                   # true
E ≤ sqrt(eps(I))*norm(I)  # true

atolrtol はそれぞれ目標とする絶対誤差および相対誤差で、E がこれらのうち大きいほうを下回るまで精度を上げながら反復を繰り返します。ただし、E はあくまで推定値であり I と真の値の正確な誤差ではありません。しかし、少なくとも I が収束した場合には E <= max(atol, rtol*norm(I)) が真となることが期待できます。言い換えれば、その条件が満たされていない場合、I は既定の反復回数のうちに収束しなかったことを意味しており、信頼できない値であると考えられます。

基本的には積分値の大きさに応じた精度を要求できる rtol を使うのが便利です。しかし、予想される積分値 I の絶対値が非常に小さい場合には rtol*norm(I) がアンダーフローを起こす恐れがあります。こうなると収束条件を満たせず反復回数が大きくなってしまうので、これを避けるために同時に atol にも小さい値を設定しておくと計算量を節約できる場合があります。atol が与えられた場合は、rtol は明示的に与えられない限り使われないので両方を明示的に指定しましょう。

区間には Inf を指定することができます。

# Computes ∫ 1/(1+x^2) dx in [0, ∞)
I, E = quadde(x -> 1/(1 + x^2), 0.0, Inf)
I ≈ π/2    # true

# Computes ∫ 1/(1+x^2) dx in (-∞, ∞)
I, E = quadde(x -> 1/(1 + x^2), -Inf, Inf)
I ≈ π      # true

fig.2

また、a, b 以降にも区間が与えられた場合には積分区間を分割して計算した和を返します。

\begin{align} I = \int_{a}^{b} f(x) dx + \int_{b}^{c} f(x) dx + \cdots \end{align}

これは、積分区間に不連続点・発散する点を含む場合に有用です。例えば、関数  {f(x) = \frac{1}{\sqrt{|x|}}} {x = 0} で発散しますが、区間を分割し  {x = 0} を端点にすることでうまく計算できるようになります。

# Computes ∫ 1/sqrt(|x|) dx in (-1, 1)
# The integrand has a singular point at x = 0
I, E = quadde(x -> 1/sqrt(abs(x)), -1.0, 0.0, 1.0)
I ≈ 4    # true

fig.3

得意・不得意な関数の傾向

基本的には被積分関数  {f(x)} が積分区間全域にわたって緩やかに変化するような関数は効率よく計算するようです。逆に積分区間の一部だけ激しく変化するような関数の場合は、最も変化の激しい部分に引っ張られて全体の評価点数を増やすので効率が良くないです。これは非適応型の宿命といえます。具体的な例を挙げると、積分区間にスパイクが発生するような関数は評価回数が多くなってしまいます。

fig.4

また、上でも述べられたように端点の特異性に関してはほかのアルゴリズムと比べても強いため、ほかのアルゴリズムでは対応できない場合に試してみるのはよいかもしれません。

QuadGK との比較

→nbviewerでみる

gist.github.com

感想など

書く前は難しそうだと思っていたので、思ったよりもちゃんと動くな、というのが正直な感想です。いままで数値積分関数はブラックボックスとして扱っていたので勉強になりました。数値積分関数の説明には往々にして注意書きがいろいろあったり、「必ずしも正しい値を返すとは限りません」みたいな歯切れの悪い文言が並んでいたりするものなんですけれども、なんとなく理由がわかったような気がします。上のスパイクが発生するような関数なんかは二重指数関数型数値積分法に限らず数値積分関数にとっては悪意の塊みたいな関数なんだな、と。

汎用性のために一部速度や精度を犠牲にしている部分もあります。また、二重指数関数型積分法には被積分関数によっていろいろな変種が考案されているので、汎用の関数を書いておいてなんですが問題にあわせて最適化したものをつかうのが一番ですね。それでもほとんどの被積分関数に関して7~8桁(デフォルト)の精度での計算には問題なく動くようです。13桁かそれ以上を目指す場合には被積分関数によっては厳しいかもしれないです。

今のところはかなり素朴な実装 (v0.0.5) になっていて特にひねりもないので、何か思いついたら改良していこうと思います。

Julia の Language server を Vim でうごかす その2

以前、こういう記事を書きました。

machakann.hatenablog.com

しかし、準備が煩雑、必要なパッケージをグローバルにインストールしなければならないなど不満が多くありました。なので、これらの不満を解決するべく必要な設定やソースコードを一つの Vim plugin にまとめました。

GitHub - machakann/vim-lsp-julia: The Julia programming language support for vim-lsp using LanguageServer.jl

必要な準備は julia コマンドにパスを通すこと、vim-lsp をインストールすることぐらいです。詳しくは vim-lsp-julia のREADME かドキュメントを読んでください。

注意としてはおそらく最初の起動時にはサーバーの準備ができるまでかなり時間がかかります。私のノートPCだと 20~30 分かかったような…?ほとんどは SymbolServer.jl がキャッシュを作るのにかかっていると思われます。なのでインストールしているパッケージの数にもよると思います。将来的にはよくなるかもしれません。

g:lsp_julia_depot_path 変数にパスを設定することでコンパイルキャッシュやログを外に切り出せるようにしたんですけど、肝心の SymbolServer.jl のキャッシュ (作成に時間がかかる) をまだ外に出せないので中途半端な感じです。これも将来的には解決できそうです。

また Vim のカラースキームを書いた

雪山かなんかの写真をみてなんかこんな感じ、と作りはじめました。

GitHub - machakann/vim-colorscheme-snowtrek: A light colorscheme for vim text editor

かなり明るめです。いままでは、カラースキームを作る場合 'background' オプションが light の場合と dark の場合の両面をつくっていたんですけど、今回はあっさり放棄しました。明るい色使いだけです。

snowtrek

わりと気に入ってて、特に vim の :help を読むのが気分いいです。個人的に。

vimhelp

Julia 言語の丸め関数

Julia 言語の丸め関数についてまとめました。

丸め関数

floor, ceil, trunc, round があります。

trunc(x)

原点 0 へ向かう方向に最も近い整数へと丸めます。

trunc(-1.1) == -1.0
trunc(-0.9) == -0.0
trunc( 0.9) ==  0.0
trunc( 1.1) ==  1.0

trunc

ceil(x)

小数点以下切り上げです。正方向に最も近い整数へと丸めます。

ceil(-1.1) == -1.0
ceil(-0.9) == -0.0
ceil( 0.9) ==  1.0
ceil( 1.1) ==  2.0

ceil

floor(x)

小数点以下切り捨てです。負方向に最も近い整数へと丸めます。

floor(-1.1) == -2.0
floor(-0.9) == -1.0
floor( 0.9) ==  0.0
floor( 1.1) ==  1.0

floor

round(x)

いわゆる四捨五入ですが、デフォルトだと言葉通りの四捨五入にはなりません。次の "RoundingMode について" を参照してください。

floor(-1.1) == -1.0
floor(-0.9) == -1.0
floor( 0.9) ==  1.0
floor( 1.1) ==  1.0

RoundingMode について

round 関数は丸めたい浮動小数点数につづいて RoundingMode を指定することで挙動を変えられます。差異は小さいながら、時に重要なので違いを理解しておくことが重要です。

RoundNearest

round 関数のデフォルトの丸め規則です。半整数 ( ... -2.5, -1.5, -0.5, 0.5, 1.5, 2.5, ...) は 最も近い偶数 へ丸められるという、ちょっとトリッキーな動きです。

※2019/8/4 追記: Banker's rounding というらしいです。統計量の偏りを小さくすることを目的とした工夫みたいです。こちら の記事がわかりやすかった。

round(-1.5) == round(-1.5, RoundNearest) == -2.0
round(-0.5) == round(-0.5, RoundNearest) == -0.0
round( 0.5) == round( 0.5, RoundNearest) ==  0.0
round( 1.5) == round( 1.5, RoundNearest) ==  2.0

RoundingMode:RoundNearest

RoundNearestTiesAway

半整数は原点 0 から離れる方向に丸められます。絶対値を丸めているとも言えます。C/C++ 言語の round 関数と同じ挙動です。

round(-1.5, RoundNearestTiesAway) == -2.0
round(-0.5, RoundNearestTiesAway) == -1.0
round( 0.5, RoundNearestTiesAway) ==  1.0
round( 1.5, RoundNearestTiesAway) ==  2.0

RoundingMode:RoundNearestTiesAway

RoundNearestTiesUp

半整数は正の方向へ切り上げられます。Java/JavaScript 言語の round 関数と同じ挙動です。

round(-1.5, RoundNearestTiesUp) == -1.0
round(-0.5, RoundNearestTiesUp) == -0.0
round( 0.5, RoundNearestTiesUp) ==  1.0
round( 1.5, RoundNearestTiesUp) ==  2.0

RoundingMode:RoundNearestTiesUp

RoundToZero

trunc 関数と同じく原点 0 へ向かって丸めます。trunc(x) == round(x, RoundToZero) となります。

RoundUp

ceil 関数と同じく小数点以下切り上げです。ceil(x) == round(x, RoundUp) となります。

RoundDown

floor 関数と同じく小数点以下切り捨てです。floor(x) == round(x, RoundDown) となります。

RoundFromZero

ドキュメントによると BigFloat 型の数値を丸める場合にのみ使われるようです。よくわかりません。


個人的には、ほぼ整数だとわかっている数の Int 型への変換に RoundNearest を、ランダムな浮動小数点数を丸めたい場合に RoundNearestTiesUp を使っていることが多いような気がします。

# a, b は半整数
c = round(Int, a + b)

# d ∈ [-3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0]
d = round(cos(rand(Float64)*π)*3, RoundNearestTiesUp)

参考

肥大した $HOME/.julia を整理する

  1. Julia の REPL を立ち上げる
  2. ] キーを押して Pkg REPL モードに入る
  3. gc と入力して Enter

つまり、REPL を立ち上げて ]gc<Enter> 。簡単ですね!

必要なくなったバージョンのパッケージリポジトリのコピー (たぶん $HOME/.julia/packages/{package name}/ 以下にあるやつ) を削除するらしいです。Makie.jl, AbstractPlotting.jl, GLMakie.jl の master を追いかけていたら .julia フォルダのサイズが大変なことになってました。たまに自動で走らせたいけど頻度に悩む。

11. REPL Mode Reference · Pkg.jl