Vimの「バッファ」「ウィンドウ」「タブ」の違いを整理する
「バッファを閉じたつもりがウィンドウが残っている」「タブを閉じたのにファイルはまだ開いていた」。この3つの用語を混同していると、こういう挙動に何度も驚かされます。似た場面で使う言葉ですが、指しているものはそれぞれ別物です。
目次(8項目)
結論:タブ ⊇ ウィンドウ、ウィンドウとバッファは別物
この3つを1本の入れ子で説明しようとすると、途中で辻褄が合わなくなります。ウィンドウがタブに属するという関係は入れ子で構いません。ウィンドウは必ずどれか1つのタブの中にあり、タブをまたいで存在することはできません。一方、ウィンドウとバッファをつないでいるのは所属ではなく参照です。ウィンドウは「今どのバッファを映しているか」という情報を1つ持っているだけで、バッファを内側に抱え込んではいません。公式ヘルプがウィンドウをviewport(覗き窓)と呼んでいるのは、この違いを指しています。
A buffer is the in-memory text of a file. A window is a viewport on a buffer. A tab page is a collection of windows.
覗き窓が2つあっても、覗かれている景色は1つのままです。:splitでウィンドウを増やしても、そこに映っているバッファの番号はどちらも同じままでした。
| 用語 | 指すもの | 閉じる代表コマンド |
|---|---|---|
| バッファ | メモリ上のファイルの中身 | :bdelete |
| ウィンドウ | バッファを表示する枠 | Ctrl-w c / :close |
| タブ | ウィンドウの配置をまとめた単位 | :tabclose |
同じバッファは複数のウィンドウ・タブに同時に映る
参照であることを確かめる一番早い方法は、同じバッファを2か所に映して、片方だけを書き換えてみることです。vim -Nu NONE a.txtで起動して:splitし、上のウィンドウで1行目をXXXに変えてから、下のウィンドウが映しているバッファの1行目をgetbufline()で読み出しました。
:split
:echo "win=" . winnr("$") . " bufs=" . string(tabpagebuflist())
win=2 bufs=[1, 1]
:1wincmd w " 上のウィンドウへ移動して ggcwXXX と打つ
:echo getline(1) . " / " . getbufline(winbufnr(2), 1)[0]
XXX / XXX
触ったのは上のウィンドウだけですが、下のウィンドウが映している1行目もXXXになっています。画面上でも上下の表示が同時に書き換わり、2本のステータス行に[+](未保存の変更あり)が同時に立ちました。ウィンドウごとに中身の写しを持っているなら起きない挙動です。
タブをまたいでも変わりません。a.txtを開いたまま:tabnew a.txtを実行し、2枚目のタブで1行目の先頭にQQを挿入してから、win_getid(1, 1)すなわち1枚目のタブの1番目のウィンドウが映しているバッファを読み直しました。
:tabnew a.txt
:ls
1 %a "a.txt" line 1
" 2枚目のタブで ggIQQ と打ったあと
:echo "tab1win1 buf=" . winbufnr(win_getid(1, 1)) . " line1=" . getbufline(winbufnr(win_getid(1, 1)), 1)[0] . " / tab2 line1=" . getline(1)
tab1win1 buf=1 line1=QQAAA / tab2 line1=QQAAA
タブを増やしてもバッファ一覧は1行のままで、番号も1のままです。2枚目のタブで入れたQQは、1枚目のタブのウィンドウが映しているバッファにもそのまま乗っています。他のエディタのタブに慣れていると「別のタブで開いたのだから別の写し」と考えがちですが、Vimのタブにファイルを複製する働きはありません。
バッファとは、メモリに展開したファイルの中身
バッファは、開いてメモリに読み込んだファイルの内容そのものを指します。ここで重要なのは、バッファは「表示されているかどうか」とは無関係に存在できるという点です。バッファには3つの状態があります。
| 状態 | 意味 |
|---|---|
| アクティブ | 内容がウィンドウに表示されている |
| 隠れ(hidden) | 表示はされていませんが、内容はメモリに読み込まれたまま |
| 非アクティブ | 表示されておらず、内容もメモリに読み込まれていない |
ウィンドウを閉じてもバッファはVimの中に残ります。ただし「隠れ」で残るのか「非アクティブ」まで落ちるのかは設定で変わり、そこが冒頭の「閉じたつもりが残っている」の正体にあたります。条件はこの記事の後半で実測します。
もう1つ押さえておきたいのは、バッファ番号がVim全体でひとつながりで、タブにもウィンドウにも属していないことです。1枚目のタブでだけ:splitを打った状態で、それぞれのタブに移動して数を取ると次のようになりました。
:echo "tab1: win=" . winnr("$") . " listed=" . len(getbufinfo({"buflisted": 1}))
tab1: win=2 listed=1
:echo "tab2: win=" . winnr("$") . " listed=" . len(getbufinfo({"buflisted": 1}))
tab2: win=1 listed=1
winnr("$")はタブごとに違う値を返すのに、リストされたバッファの個数はどちらのタブから数えても同じです。:lsのa(アクティブ)も同じ理屈で、「今のタブに映っている」ではなく「Vimのどこかのウィンドウに映っている」を意味します。2枚目のタブでb.txtとc.txtを開いてから1枚目のタブへ戻って:lsを打つと、画面に出ていない2つにもaが付きました。
:ls
1 %a "a.txt" line 1
2 a "b.txt" line 0
3 a "c.txt" line 0
記号そのものの読み方と絞り込み方はバッファ一覧(:ls)の読み方に分けてあります。
ウィンドウとは、バッファを表示する枠
ウィンドウが自分で持っているのは、どのバッファを映すかという情報と、表示のための状態です。カーソル位置・スクロール位置・折りたたみの開閉・ウィンドウローカルなオプションはウィンドウ側にあり、行の中身はバッファ側にあります。6行のファイルを:splitで分けて片方だけGで最終行へ飛ばすと、line(".")は6と1に分かれ、winbufnr()はどちらのウィンドウでも1のままでした。同じ中身を別々の場所から見ている状態がこれにあたります。
ウィンドウの数え方は2種類あります。winnr()はタブの中での並び順の番号で、ウィンドウを閉じたり入れ替えたりするたびに振り直されます。win_getid()が返すウィンドウIDのほうは、Vimを起動している間ずっと変わらず、タブをまたいで一意です。タブを2枚、それぞれにウィンドウを2つ作った状態で問い合わせると、番号順とID順が一致しないことが見えます。
:echo "tab1win1=" . win_getid(1, 1) . " tab2win1=" . win_getid(1, 2) . " tab2win2=" . win_getid(2, 2)
tab1win1=1001 tab2win1=1003 tab2win2=1002
2枚目のタブは:tabnew b.txtで作ってから:vsplit c.txtで左側を足したので、あとから作った左のウィンドウが1番目になり、IDは先に作った右側より大きい1003です。IDは作られた順、番号は並んだ順ということです。スクリプトから特定のウィンドウを覚えておきたいときは、あとで並びが変わっても壊れないIDのほうを持ちます。ウィンドウの作り方は画面分割の方法、閉じ方はウインドウを閉じる4つの方法、間の移動はウインドウ間のカーソル移動にまとめています。
タブとは、ウィンドウの集合
タブは、ウィンドウをレイアウトして配置する領域を指します。1つのタブの中に複数のウィンドウを分割配置できます。ブラウザのタブのように「1タブ1ファイル」で使う人も多いですが、Vimのタブはあくまでウィンドウ配置の切り替えであって、ファイルそのものを閉じる機能ではありません。タブとウィンドウの一覧は:tabsで確認できます。
「配置を覚える枠」という言い方が誇張でないことは、セッションが何を保存するかに表れています。vim -Nu NONEで起動した直後の'sessionoptions'は次の値で、tabpagesとwinsizeが既定で入っています。
:echo &sessionoptions
blank,buffers,curdir,folds,help,options,tabpages,winsize,terminal
タブの枚数とウィンドウの大きさは復元対象で、バッファはbuffersという別の項目として並んでいます。保存と復元の手順はセッションの保存と復元、タブバーそのものの出し分けはタブバーの表示切り替えにまとめています。
:tabsの出力もファイルの一覧ではなく、タブごとのウィンドウの並びです。1枚目にa.txtを上下分割、2枚目にc.txtとb.txtを左右分割した状態では、同じa.txtが1枚目に2回出てきます。行が2つあってもバッファは1つです。
:tabs
Tab page 1
> a.txt
a.txt
Tab page 2
c.txt
b.txt
閉じる操作で何が残り、何が消えるか
ウィンドウを閉じたあとバッファがどう残るかは、'hidden'オプションで変わります。vim -Nu NONEの既定はオフで、この状態で:split b.txtしてから:closeすると、b.txtは一覧に残るものの記号の桁が空になりました。
:echo &hidden
0
:split b.txt
:close
:ls
1 %a "a.txt" line 1
2 # "b.txt" line 0
:echo bufloaded("b.txt") . " " . bufexists("b.txt")
0 1
一覧には並んでいるのに、中身はメモリから捨てられている状態です。同じ手順を:set hiddenのあとで繰り返すとhが付き、末尾もline 0からline 1に変わりました。カーソルがどの行にいたかまで抱えたまま残るということです。
:set hidden
:split b.txt
:close
:ls
1 %a "a.txt" line 1
2 #h "b.txt" line 1
ここはVimとNeovimで既定が違います。NVIM v0.12.4をnvim -u NONEで起動しても&hiddenは1を返しました。そのため、変更のあるバッファを表示から外そうとしたとき、Vim 9.1.1752はE37: No write since last changeで止めますが、Neovimは黙って隠します。Neovimで変更を残したまま:edit b.txtを打ったあとの:lsは次のようになり、a.txtに未保存の印が付いたまま隠れているのが見えます。
1 #h + "a.txt" line 1
2 %a "b.txt" line 1
逆向きも見ておきます。:bdeleteはバッファを一覧から外す操作ですが、そのバッファを映していたウィンドウは行き先を失います。a.txtとb.txtを上下に開いた状態で:bdelete a.txtを打つと、映すものが無くなった側のウィンドウごと閉じました。
:echo "before: win=" . winnr("$") . " buflist=" . string(tabpagebuflist())
before: win=2 buflist=[2, 1]
:bdelete a.txt
:echo "after: win=" . winnr("$") . " buflist=" . string(tabpagebuflist())
after: win=1 buflist=[2]
:tabcloseはこの逆で、タブの中のウィンドウを片付けるだけです。2枚目のタブでb.txtを開いてから:tabcloseしてタブを1枚に戻しても、b.txtは:lsに残ったままでした。ファイルの側を片付けたいなら、タブでもウィンドウでもなくバッファに向かって:bdeleteを打つ必要があります。
:tabnew b.txt
:tabclose
:echo tabpagenr("$")
1
:ls
1 %a "a.txt" line 1
2 "b.txt" line 0
最初はタブを「ファイルを閉じる単位」だと思い込んでいて、:tabcloseしたあとに:lsを打ったら消したはずのファイルがそのまま並んでいて、しばらく画面を見つめていました。同じ勘違いを分割でもやっていて、上下に開いた同じファイルの上側で行を消したら下側からも同じ行が消えて、自分がどちらを壊したのか一瞬分からなくなりました。
まとめ
バッファ・ウィンドウ・タブは上下に積まれた3階層ではなく、寿命の違う3つの入れ物です。ウィンドウを閉じてもバッファは残り、タブを閉じてもバッファは残ります。バッファを消したときだけ、行き先を失ったウィンドウが巻き込まれて閉じることがあります。この非対称を知っておくと、後片付けの結果が予想とずれなくなります。実際のタブ操作の詳しいコマンドはタブ操作の記事、複数ファイルへの一括処理はwindo/bufdo/tabdoの記事にまとめています。