標準愚痴出力

個人的なIT作業ログです。もしかしたら一般的に参考になることが書いているかもしれません(弱気

1byte ではなく、1 runeずつ文字列を伸ばしてゆく一番速い方法は?

(追記)最終的には strings.Builder の優勝です。最後まで読みましょう

「1 byte ずつ」というのは今まで何回も計測されてきて、

  • append > strings.Builder > 文字列の加算

というのが定説になっている。が、「1 rune ずつ」の場合はどうだろうか?

というわけで計測してみた。

// main_test.go

package runebuffer

import (
    "strings"
    "testing"
)

// 定番の strings.Builder
func join1(L []rune) string {
    var buffer strings.Builder
    for _, r := range L {
        buffer.WriteRune(r)
    }
    return buffer.String()
}

// append で rune のスライスを伸ばして、最後に string 化
func join2(L []rune) string {
    buffer := make([]rune, 0, 10)
    for _, r := range L {
        buffer = append(buffer, r)
    }
    return string(buffer)
}

// rune を string 化してから、[]byte に append して、最後に string 化
func join3(L []rune) string {
    buffer := make([]byte, 0, 30)
    for _, r := range L {
        buffer = append(buffer, string(r)...)
    }
    return string(buffer)
}

func test(b *testing.B, f func([]rune) string) {
    for i := 0; i < b.N; i++ {
        f([]rune{'あ', 'い', 'う', 'え', 'お'})
    }
}

func Benchmark1(b *testing.B) {
    test(b, join1)
}

func Benchmark2(b *testing.B) {
    test(b, join2)
}

func Benchmark3(b *testing.B) {
    test(b, join3)
}
$ go test -bench .
goos: windows
goarch: amd64
Benchmark1-4     6095110               175 ns/op
Benchmark2-4     8638275               137 ns/op
Benchmark3-4     9762049               121 ns/op
PASS
ok      _/C_/Users/hymko/runebuild      4.186s

rune を string 化してから、[]byte に append して、最後に string 化するのが一番速いか。ただし、事前にちゃんとバッファのキャパシティーを広めに確保(make([]byte,0,30)) しておくのは必須。

追記

allocation 回数的にも有利だった。

$ go test -bench . -benchmem
goos: windows
goarch: amd64
Benchmark1-4     6822398           165 ns/op          80 B/op          4 allocs/op
Benchmark2-4     8828917           139 ns/op          64 B/op          2 allocs/op
Benchmark3-4    10006144           120 ns/op          48 B/op          2 allocs/op
PASS
ok      _/C_/Users/hymko/runebuild  4.273s

さらに追記

strings.Builder版だけ、事前の領域確保が少ないせいで遅いのでは疑惑があったので、

 var buffer strings.Builder
    buffer.Grow(30)

を入れてリトライしてみた。

goos: windows
goarch: amd64
Benchmark1-4     9995352           111 ns/op          64 B/op          2 allocs/op
Benchmark2-4     8700891           138 ns/op          64 B/op          2 allocs/op
Benchmark3-4     9842050           123 ns/op          48 B/op          2 allocs/op
PASS
ok      _/C_/Users/hymko/runebuild  5.074s

Oh...逆転してしまった。allocation は本当にコストがかかるんだなぁ

go get <URL> から https:// を削除する

ちょっと時期を逸してしまいましたが…

こんな感じの Lua コードを nyagos で読み込めば(lua_f nohttps.lua を実行するか、 .nyagos に書く)、ALT+O で今編集中のワードの中の https:// を削除してくれます。

# nohttps.lua
nyagos.key.M_o = function(this)
    local word,pos = this:lastword()
    word = string.gsub(word,"https://","")
    this:replacefrom(pos,word)
end

コマンドプロンプトの文字幅をキャリブレーションして、崩れない TUI 画面を作ろう

本文書は コマンドプロンプトの文字幅をキャリブレーションして、崩れない TUI 画面を作ろう - Qiitaよりの転載です。


最近、コマンドプロンプトで動作する CSVビューア とか、ツイッタクライアント を書いてます。

この手のツールを書く時、本格的なフレームワークライブラリもいいのですが、欧米人の書いたやつはなんか表示のズレが多く、めんどくさいことが多い(大偏見)ので自分は

という三種の神器的なライブラリでやることが多いです。この3セットで書いてたら、普通に Windows / Linux 双方対応でき、お手軽に OS への依存性を除くことができるので、自分は重宝しています。

が、それでも画面崩れるんですよね。ツイッタクライアントで画面を見てると、たまに1行に文字を出力しすぎて画面がスクロールしてしまう。調べてみると、一部の文字が原因。そう文字幅が想定と違うんです。

一例をあげると、たとえば「✧」(U+2727 : WHITE FOUR POINTED STAR)。これ、Unicode の規格では1セル分扱いのハズなんですが

自宅のWindows10でのスクリーンショット

→ 1セル分しかカーソルが移動しない(%U+nnnn% は nyagos の拡張機能

会社のWindows7でのスクリーンショット → 2セル分カーソルが移動している

環境によって表示した時のカーソル移動量が違うんですね。これはどうしたものかと思っていたら、先生から啓示が出ました「フォントです」「フォントですか」。つまり、Windows 7だからこの幅、10だからこの幅みたいな判断もできない。

ここに Windowsコマンドプロンプト向けのバッタもん runewidth( go-lunewhydos という名前まで考えてた)開発計画は一旦頓挫したのでした。

ならば、端末ごとにデータベース作ればいいんじゃね?

いわゆる「キャリブレーション」というワードが脳内にポップアップしました。昔、ブラウン管のモニターの表示を調整するのに、ユーザオペレーションで画面位置を調整していましたし、今でも PS4 のゲームとかで表示調整する時に「龍がギリギリ見えるところを選択してください」みたいなことをやりますよね。ユーザ操作まではいらないけども、端末ごとに1アクションして、結果をデータベースとして残すようにすればよいのです。

ということで、Unicode での公式幅と違う幅の文字を検出し、それを参照するライブラリ go-termgap (端末のギャップ)を作成しました。

原理

  • \r +目的の文字」を表示してから、カーソル位置を調べる」ということを全Unicode文字に対して行う

わかりやすいな

どないして、カーソル位置を取得すんねん

Goから OS の API を呼ぶというと、一般には import "cgo" を使うことが普通とされています。ですが、これ C言語が標準装備された UNIX ではよいのですが、Windows では要件が増えてビルドするための敷居があがってしまいます。

Windows の場合、それよりも "syscall" を使って、DLL をロードして、そちらから API を呼び出すのがよいでしょう。これならビルド要件はあがりません。

で、さらに最近は、そこまでせずとも、実は "golang.org/x/sys/windows" に既に多くの DLL 関数が定義されていて、そこに定義されていたら、それを使うだけで済んでしまいます。

それを使って実装してみたのが、こちら:

func X()(int,error){
    handle, err := windows.GetStdHandle(windows.STD_ERROR_HANDLE)
    if err != nil { return 0 ,err }

    var buffer windows.ConsoleScreenBufferInfo
    windows.GetConsoleScreenBufferInfo(handle, &buffer)
    return int(buffer.CursorPosition.X),nil
}

さて、この文字幅計測関数を、サロゲートペアになっていない全ユニコード(※)で実際に動かしてみましょう!

思ったほど時間がかかりませんでした。1分もかかってない。最初に1回やっておくだけでいいならば十分許容範囲です。これをどこかに保存しておけばよいわけです。普通に "encoding/json" でも使っておきましょうか。

これをどこにおくか…ですが、Go言語にはユーザ向けのディレクトリを得る関数が3つ用意されています。

func os.UserCacheDir() (string, error)
func os.UserConfigDir() (string, error)
func os.UserHomeDir() (string, error)

Windows では、

  • os.UserCacheDir()%USERPROFILE%\AppData\Local
  • os.UserConfigDir()%USERPROFILE%\AppData\Roaming

となっていることが多いようです。Roaming フォルダーは、例えば PC を移行した時にユーザに紐づく情報としてもってゆくデータを入れる場所らしいです。こんな端末に紐付いたデータは置くべきではないので、Local の方にフォルダーを掘って、そこに保存することにしましょう ( → %USERPROFILE%\AppData\Local\nyaos_org\termgap.json とした)

ライブラリ利用編

では、作ったライブラリを利用してみましょう!

    db, err := termgap.New()
    if err != nil {
        return err
    }
    w, err := db.RuneWidth('\u2727')
    if err != nil {
        return err
    }
    fmt.Printf("[\u2727]'s width=%d.\n", w)

めんどくさ! いちいち、インスタンス作らなきゃいけないのかよ!しかも、文字幅データベース(JSON ファイル)が作成されてなかったら、New() でエラー!?つかえん!

というわけで、ラッパーライブラリを作りました。これならどうでしょう。

import (
    "fmt"
    "github.com/zetamatta/go-termgap/hybrid"
)

func main() {
    fmt.Printf("[A]'s width=%d\n", hybrid.RuneWidth('A'))
    fmt.Printf("[\u2727]'s width=%d\n", hybrid.RuneWidth('\u2727'))
}

はい、インラインで使えますね。これなら許容範囲です。

でも、文字幅データベースが作られてない環境だとどうなるんだ?Panic ?

ノンノン、Panic など愚の骨頂。文字幅データベースがないなら、かわりにどっかからデフォルト値をもってくればよいのです。たとえば人様のライブラリを使って…(悪い顔)

※ だから、パッケージ名が hybrid なんです、はい

なお、Linux の場合ですが、キャリブレーションの方はともかくとして、データベースを利用する側は JSON ファイルが見つからず、普通に go-runewidth にぜんぶ丸投げするだけなので、特にビルド上の問題などはありません。

以上、人様のライブラリにおんぶにだっこしたい同志各位のご参考になれば幸いです。

Go言語の実行ファイルのパッケージを作成するバッチファイル

GOOS=windows or linux、GOARCH=amd64 or 386 という組み合わせで、(フォルダー名)-(年月日)-(GOOS)-(GOARCH).zip という zip ファイルを作ります。

go-package.cmd

@echo off
setlocal
set "PROMPT=$ "
for %%I in (%CD%) do set "NAME=%%~nI"
for %%I in (windows linux) do for %%J in (amd64 386) do call :one %%I %%J
endlocal
exit /b

:one
    set GOOS=%1
    set GOARCH=%2
    set SUFFIX=
    if %GOOS% == windows set "SUFFIX=.exe"
    echo GOOS=%GOOS% GOARCH=%GOARCH%
    @echo on
    go build
    zip -9 %NAME%-%DATE:/=%-%GOOS%-%GOARCH%.zip %NAME%%SUFFIX%
    @echo off
    exit /b

これで作ったリリースがこちら

追記 (2019.11.02)

ちょっと直しました。

@echo off
setlocal
set "PROMPT=$ "
for %%I in (%CD%) do set "NAME=%%~nI"
for %%I in (windows linux) do for %%J in (386 amd64) do call :one %%I %%J
endlocal
exit /b

:one
    set GOOS=%1
    set GOARCH=%2
    set SUFFIX=
    if %GOOS% == windows set "SUFFIX=.exe"
    echo GOOS=%GOOS% GOARCH=%GOARCH%
    @echo on
    go build -ldflags "-s -w"
    zip -9 %NAME%-%DATE:/=%-%GOOS%-%GOARCH%.zip %NAME%%SUFFIX%
    @echo off
    exit /b
  • ビルドする順番が amd64 → 386 だったのを 386 → amd64 にした
    • あとに残った実行バイナリが 386 だと、WSL で Linux 版を即テストできないから
  • go のオプションに -ldflags "-s -w" を追加して実行ファイルのサイズを小さくした

以上です

CSV のビューアを作った

仕事でプログラム間の連携を TSV ファイルで行うことが多く、しかも Excel とか仮想マシン上に入ってなかったりすることが多い(し、あっても重くて使いたくない)ので、意を決して CSV ビューアを作りました。

先日のツイッタークライアントは、一応自分で使う分には問題ないけど、人に使ってもらうには足りない点がいろいろあって未だ完成していないのですが、こちらは着手から半日くらいであっさり出来ました。もっと早く作ればよかった!

  • セルの表示は背景色を青/黒の繰り返しで表現。
    • ただ、ちょっと目がチカチカするので、もうちょっとよい表現があれば、変えたい
  • 最大桁を12桁
    • それ以上は切り捨てる
    • 全データは最下行に表示する
    • 1セル内の行を複数表示する場所がないので、改行は「⤶」と表示
  • キーバインドは HJKL/Ctrl-FBNP/カーソルキー
  • 使用ライブラリは、いつもの mattn 先生の Go言語コンソール三種の神器go-colorable / go-tty / go-runewidth

開発のプロセスは下記のような感じです。

  1. 最初は上から下に市松模様?に CSV をダンプするだけのプログラムを作る
  2. ライブラリ化して、それを n 行目から画面行数分だけ表示する形に改める。
  3. 指定した行の指定した列のセルだけ色を変えられるようにする。
  4. キー入力させて、そのセルの場所をプラスマイナスできるようにする。
  5. 表示開始位置もカーソル位置に応じて変えられるようにする

ね、簡単でしょ?

nyagos 4.4.5_2 を公開しました。

もう nyagos もアーカイブモードも近いかなと思っていたのですが、ちょっと致命的な不具合が見つかったので、急遽修正しました。

  • (#375) ~randomstring でクラッシュする不具合を修正

これは UNIX 系の ~username をホームディレクトリに置換するところなんですが、username が見つからんかったときに何もなかったかのように戻す処理があります。そこに不具合がありました。panic dump のリポートが出るので場所はすぐわかりましたが、そこを本来はどう直すべきであったか、思い出すのにちょっと手間取りました(汗

  • (#374) 未来のタイムスタンプのファイルのls -lで西暦がでなかった不具合を修正

以前、仕事で未来のタイムスタンプ(2020年)のファイルを作ったのですが、ls -ltr で年が出ず、「表示を見ると(年が出ないので)過去に見えるのに、ソート順では最も新しい側に見える」という妙な症状が出たことで発覚しました。

Git for Windows に付属の ls で本来の動作を確認したところ、ls -l で日付の後に年ではなく、時刻が出る条件は、タイムスタンプが半年前~今の間にある時だけのようです。nyagos 内蔵 ls では、1年前より未来がすべて時刻表記になっていたので、標準の ls に挙動をあわせました。

以上、よろしくお願いいたします。