👽

GoでUNIXコマンドを作りながら、I/O処理を理解する

に公開

https://qiita.com/advent-calendar/2025/go

はじめに

本記事では、Goを使って簡易的なUNIXコマンド「cat・ls・grep・wc」を作っていこうと思います。
この記事を通して、GoとUNIXの相性の良さを感じたり、Goを楽しんでいただければと思っています!

準備

今回 shell風なインタラクティブな環境の中で簡易UNIXコマンドが実行できたらいいなということでそれ風なものを作っていく作業から始めます。
ここで作るのは、zshやbashなどといった本格的なシェルではなく、あくまで「標準入力を受ける」「入力された文字列をコマンドと引数に分解する」「対応する処理を実行する」というコマンド実行の最小構成を作っていきます。

まずは、その土台となるコードを書いていきます。

土台となるコード
package main

import (
	"bufio"
	"fmt"
	"io"
	"os"
	"strings"
)

func main() {
    // 標準入力から1行ずつ読み取る
	reader := bufio.NewReader(os.Stdin)

	for {
		fmt.Print("> ")
		input, err := reader.ReadString('\n')
		if err != nil {
			fmt.Fprintln(os.Stderr, err)
			continue
		}

		if err := execInput(input); err != nil {
			fmt.Fprintln(os.Stderr, err)
		}
	}
}

func execInput(input string) error {
	// 改行を削除して前後の空白を無くしていく
	input = strings.TrimSpace(input)

	if input == "" {
		return nil
	}

	// スペースで分割して、コマンドと引数を分ける
	args := strings.Split(input, " ")
	command := args[0]

	switch command {
	case "cat":
		// TODO: ここにcatの実装をしていきます
	case "exit":
		fmt.Println("Bye!!")
		os.Exit(0)
	default:
		return fmt.Errorf("command not found %s", command)
	}

	return nil
}

こちらが記述できたら、go run main.goを実行してみましょう。

go run main.go
> 

上記のようにプロンプトが表示されれば成功です。
exitでプログラムを終了できます。
なお、上記のコードで扱っているos, bufioなどのパッケージについては各コマンドを作る際にまとめて解説していきます。

鍵となってくるパッケージたち

今回のコマンドたちを実装していく上で欠かせないパッケージが、以下の3つです。

I/Oの読み書きメソッドはio.Readerとio.Writerに詰まっている

ファイルもネットワークも標準入出力も、すべて同じように扱えるのがGoの強いところです。
Goではあらゆる入出力がこのReaderとWriterというインターフェースに統一されています。

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

Readerは、「読み込む機能をまとめて、抽象化した」インターフェースです。
以下は全てReaderインターフェースを満たしています。

  • os.Stdin
  • os.File
  • net.Conn

つまり、どこからデータを読み込むかに関わらず、同じReadメソッドが扱えます。

続いて、Writerは「書き込み機能をまとめて、抽象化した」インターフェースです。
以下は全てWriterインターフェースを満たしています。

  • os.Stdout
  • os.File
  • net.Conn

こちらも同様に、どこへ書き込むかを意識せずにWriteメソッドだけで操作できるようになっています。

特に注目してほしいのは、ReaderとWriterの例を見ると分かるように、os.Fileやnet.Connのような型がReaderとWriterのどちらも満たすという点です。

GoがこういったI/Oをインターフェースで抽象化しているのは、
ファイル、ネットワーク、標準入出力といった異なる入出力先を「同じコードで扱える」ようにするためです。
このおかげで、入出力元の違いを意識せずに同じ処理ロジックをあらゆるI/Oに適用できるようになっています。

まずは触ってみる

まずは、os, ioのパッケージで読み込みと書き込みに触れてみようと思います。
最もシンプルな入出力の例としてio.Copyを使って、どのようにデータの入出力が行われるのかみていきます。

package main

import (
    "io"
    "os"
)

func main() {
    io.Copy(os.Stdout, os.Stdin)
}
$ echo "Hello, World" | go run main.go
Hello, World

こちらのio.Copyは第1引数にWriter,第2引数にReaderを受け取ります。
io.Copyは、「あるReaderから読み込み、あるWriterへ書き込む」という操作をEOF(ファイルの終わり)かエラーが発生するまで繰り返すといった関数です。

io.Copyの内部では、copyBuffer関数が呼ばれています。

func Copy(dst Writer, src Reader) (written int64, err error) {
	return copyBuffer(dst, src, nil)
}

このcopyBufferの第3引数にbufを指定するのですが、ここがnilの時に限りcopyBufferは自前で32KBのバッファを割り当てます。

copyBufferの内の実装
	if buf == nil {
    // ここで32KBのバイトスライスを確保している
		size := 32 * 1024
		/* 省略 */
		buf = make([]byte, size)
	}
	for {
     // データを読み込む
		nr, er := src.Read(buf)
		if nr > 0 {
            // 読み込んだ分だけ書き込む
			nw, ew := dst.Write(buf[0:nr])
			if nw < 0 || nr < nw {
				nw = 0
				if ew == nil {
					ew = errInvalidWrite
				}
			}
			written += int64(nw)
			if ew != nil {
				err = ew
				break
			}
			if nr != nw {
				err = ErrShortWrite
				break
			}
		}
		if er != nil {
			if er != EOF {
				err = er
			}
			break
		}
	}
	return written, err

基本的には、以下を繰り返しています。

  1. src.Read(buf)でデータを読み込む
  2. dst.Write(buf[:nr])で書き込む
  3. EOFかエラーが来るまでループ

つまり、io.Copyは基本的に32KBのバッファを1つ確保し、Read → Writeを効率よくループするとった挙動になっています。

io.Copyには最適化された経路がある

補足として、io.Copyは常にバッファを使うわけではありません。
copyBuffer内にある、次の2つのインターフェースが優先的にチェックされます。

  1. io.WriterTo
type WriterTo interface {
	WriteTo(w Writer) (n int64, err error)
}
  1. io.ReaderFrom
type ReaderFrom interface {
	ReadFrom(r Reader) (n int64, err error)
}

srcがio.WriterToまたはdstがio.ReaderFromを実装している場合、新たにバッファを確保せず直接コピーを行うといった挙動になります。
「バッファ用のメモリアロケーションが発生しない」や「データコピーの回数が減る」という2点が省略されるため、特に大きなデータや頻繁なコピーではGC負荷やコピー回数が減り結果として低オーバーヘッドな処理になります。
この辺りを深ぼってしまうと本記事の内容から外れてしまうため割愛させていただきます。
io.Copy / copyBuffer のコードはこちら



それでは、先ほど準備したコードに記述されていたTODO箇所を進めていこうと思います。

catを実装しよう

まず初めはcatから実装していこうと思います。
switch文のcaseにcatを追加します。

case "cat":
	return cmdCat(args[1:])

ファイル名のスライスを受け取り、順に内容を出力するcmdCat関数を作ります。

func cmdCat(args []string) error {
	if len(args) == 0 {
		return fmt.Errorf("no file operand")
	}

	// 各ファイルの処理を順に行う
	for _, filename := range args {
		file, err := os.Open(filename)
		if err != nil {
			// 標準エラーを出力
			fmt.Fprintf(os.Stderr, "cat: %s %v\n", filename, err)
			continue
		}

		// ファイルの内容を標準出力にコピー
		_, err = io.Copy(os.Stdout, file)
     file.Close()
		if err != nil {
			return fmt.Errorf("cat: %s %v", filename, err)
		}
	}
	return nil
}

まずは、受けとった引数の長さが0、つまりファイルが指定されなかった場合はエラーを返します。

if len(args) == 0 {
		return fmt.Errorf("non file operand")
}

次に、for文でos.Open関数(読み取り専用でファイルを開く関数)で受け取ったファイルを読み込んでいきます。

	for _, filename := range args {
		file, err := os.Open(filename)
		if err != nil {
			// 標準エラーを出力
			fmt.Fprintf(os.Stderr, "cat: %s %v\n", filename, err)
			continue
	}

io.Copyでファイル全体を標準出力にコピーします。

_, err = io.Copy(os.Stdout, file)

各ファイルの処理後、ファイルをすぐ閉じることでリソースを解放します。

file.Close()

ファイルは、開いて使わなくなったら必ず閉じるものなので処理後にファイルを閉じましょう。

os.Openの内部で何が行われているのか

ここで、os.Openの裏側で行われている処理についてまとめていこうと思います。
os.Openの実装は以下のようになっています。

func Open(name string) (*File, error) {
    return OpenFile(name, O_RDONLY, 0)
}

内部的にはOpenFileという関数が呼ばれています。

openFile関数では、
第1引数に「ファイル名」、第2引数に「読み取り専用のフラグ」0_RDONLY、第3引数の0は、パーミッションを表しています。
os.Openは、読み取り専用で既存ファイルを開くだけなので、パーミッション設定の必要がないため0が渡されています。

0_RDONLYは、osパッケージでは以下のように定義されています。

const (
	O_RDONLY int = syscall.O_RDONLY // open the file read-only.
	O_WRONLY int = syscall.O_WRONLY // open the file write-only.
	O_RDWR   int = syscall.O_RDWR   // open the file read-write.
    /* 省略 */
)

これはsyscallパッケージで定義されているフラグを参照する形になっており、

const (
    // 一部抜粋
	O_CREAT          = 0x200
	O_RDONLY         = 0x0
	O_RDWR           = 0x2
)

全て「ビットフラグ(2進数のフラグ)」になっています。
O_RDONLY = 0x0 は、読み取り専用なので何のビットも立っていないことが確認できます。

そして、実際にOSのファイルを開く処理は、openFileNolog関数が行なっています。

func OpenFile(name string, flag int, perm FileMode) (*File, error) {
	testlog.Open(name)
	f, err := openFileNolog(name, flag, perm)
	/* 省略 */
}

そのファイルは存在するかどうか、読めるかどうか、ディレクトリかどうなどなど、OSのファイルシステム的な役割を担っています。

openFileNolog関数内の冒頭で以下のようなbit演算が行われています。

if !supportsCreateWithStickyBit && flag&O_CREATE != 0 && perm&ModeSticky != 0 {
	if _, err := Stat(name); IsNotExist(err) {
		setSticky = true
	}
 }

つまり、

  • flag&O_CREATE != 0 は、createフラグが立っている
  • perm&ModeSticky != 0 はstickyビットが付いている

という意味になります。
しかし、os.Openは、0_RDONLYで開くため、ここには一切入ってきません。

最終的に、システムコールに成功すると、ファイルディスクリプタを得て、それをGoがFile構造体としてラップし、Fileオブジェクトが返されるようになっています。

これまでの実装した内容で、catが実行できるか確かめてみましょう。
適当に、sample.txtファイルを用意しましょう。

sample.txt
Hello, 世界!
GoGo~~!
go run main.go
> cat sample.txt
Hello, 世界!
GoGo~~!

うまくcatでファイルの中身を確認できました!
複数ファイルを渡した場合も順番に内容を出力するので、ぜひ試してみてください。

lsコマンドを実装しよう

この調子でlsも作っていきましょう。
switch文のcaseにlsを追加します。

case "ls":
	return cmdLs(args[1:])

新たに cmdLs関数を作成していきます。

func cmdLs(args []string) error {
	dir := "."
	if len(args) > 0 {
		dir = args[0]
	}

	// ディレクトリ内のファイルを一覧取得
	entries, err := os.ReadDir(dir)
	if err != nil {
		return fmt.Errorf("ls : %v", err)
	}

	// ファイル名を表示していく
	for _, entry := range entries {
		if entry.IsDir() {
			fmt.Printf("%s/\n", entry.Name())
		} else {
			fmt.Println(entry.Name())
		}
	}

	return nil
}

まず、以下の部分から見ていきます。

	dir := "."
	if len(args) > 0 {
		dir = args[0]
	}

dirには、lsコマンドが対象とするディレクトリパスを入れていきます。
引数がない場合はカレントディレクトリを指定するようにするため、.で初期化をしています。
その後のif文は、argsに1つ以上の値が渡されていれば、args[0]をそのままディレクトリとして使います。

続いて、以下を見ていきます。

	entries, err := os.ReadDir(dir)
	if err != nil {
		return fmt.Errorf("ls: %v", err)
	}

このlsコマンドで重要となってくるのがこのosパッケージのReadDir関数です。
ReadDirの内部では、以下の実装がされています。

func ReadDir(name string) ([]DirEntry, error) {
	f, err := openDir(name)
	if err != nil {
		return nil, err
	}
	defer f.Close()

	dirs, err := f.ReadDir(-1)
	slices.SortFunc(dirs, func(a, b DirEntry) int {
		return bytealg.CompareString(a.Name(), b.Name())
	})
	return dirs, err
}

まず openDir(name) は内部的に指定されたディレクトリを開き、*Fileを返しています。
続いて、この戻り値(f)である*Fileに対して、f.ReadDir(-1)を呼び出しています。
このReadDirメソッドは、*Fileに紐づくディレクトリ情報を読み取る役割を持っています。

func (f *File) ReadDir(n int) ([]DirEntry, error) {
	if f == nil {
		return nil, ErrInvalid
	}
	
	// readdirDirEntryは定数でiotaで整数1が割り振られています
	_, dirents, _, err := f.readdir(n, readdirDirEntry)
	if dirents == nil {
		// nil ではなく空スライスを返すようにしている
		dirents = []DirEntry{}
	}
	return dirents, err
}

上記のコードが、ReadDirメソッドの実装部分になっていて、f.readdir を通じて ディレクトリエントリ(ディレクトリ内の各エントリ情報)を読み込み、結果をDirEntryスライスに返す処理です。

ここでいう「エントリ」とは、「ディレクトリの中にある1つ1つのファイルやサブディレクトリを表す情報」のことだと思ってください。

DirEntryについて

DirEntryは、次のようなメソッドをもったインターフェースです。

type DirEntry interface {
	Name() string    
	IsDir() bool
	Type() FileMode
	Info() (FileInfo, error)
}
  • Name
    エントリが示すファイル名あるいはサブディレクトリ名を返します。
    これは、/hoge/gopher/sample.goのようなパス全体ではなく最終要素である、sample.goのみを返すようになっています。

  • IsDir
    そのエントリがディレクトリかどうかを判定します。

  • Type
    エントリの「タイプビット」を返します。ここで返されているFileModeは、通常のFileModeが持つ「権限ビット」を含まず、ファイル種別を表すビットのみを含んだサブセットになっています。
    例えば「これはディレクトリか?」「シンボリックリンクか?」といった判定に使われます。

  • Info
    エントリに登録されたファイルまたはサブディレクトリの詳細な情報(ファイル名・サイズ・権限等)を返します。

今回のlsコマンドの実装では、これらのうちName()IsDir()だけを使用しています。
ここで伝えたいのは、os.ReadDirは軽量なディレクトリエントリを返す仕組みになっている点が単純な一覧表示を行うlsに適しているという点です。

nに-1を渡すことで「すべてのディレクトリエントリを取得する」という挙動になります。

具体的には、readdirの冒頭では、

size := n
if size <= 0 {
	size = 100
	n = -1
}

n <= 0の場合、内部バッファ用にsizeを100で設定しています。その後nは強制的に-1に設定されています。

そして、次のループ条件を見るとn = -1であるため、常にtrueの状態でEOF(entptr == nil)となるまで回り続けるようになっています。

	for len(names)+len(dirents)+len(infos) < size || n == -1 {
		/* ディレクトリエントリを読み続ける */
		}
		if entptr == nil { // EOF
			break
		}
        /* 省略 */

そのため、ReadDir(-1)は「ディレクトリ内のすべてのエントリを取得する」という挙動になるわけです。

	slices.SortFunc(dirs, func(a, b DirEntry) int {
		return bytealg.CompareString(a.Name(), b.Name())
	})

そして取得後は、slice.SortFuncで取得結果を名前順でソートして返しています。


lsの最後の実装部分に移ります。

for _, entry := range entries {
		if entry.IsDir() {
			fmt.Printf("%s/\n", entry.Name())
		} else {
			fmt.Println(entry.Name())
		}
}

こちらは至ってシンプルでReadDirで取得したentries([]DirEntry)を順番に処理し、ファイル名を表示していくためのループです。
まず、entry.IsDir()で「このエントリがディレクトリかどうか」を判定します。
ディレクトリであれば / を付けて表示し、通常のファイルであればそのまま表示というだけの処理になっています。

それでは、作ったlsコマンドを試してみます。
適当にsample_dirディレクトリを作成しその中にtes1~tes3のテキストファイルを作成しておきます。
まずは、lsだけをうってカレントディレクトリ内のファイルやディレクトリが表示できるか確かめます。

> ls
go.mod
main.go
sample1.txt
sample2.txt
sample_dir/

うまく出力されました。

続いて、sample_dir内をlsで見ていきます。

> ls sample_dir
test1.txt
test2.txt
test3.txt

出力できました!

grep を実装しよう

次はgrepを実装していきます。
「grep パターン ファイル名」で指定した文字列と行をファイルから探し、その行を表示する機能を実装していきます。

switch文にgrepを追加しましょう。

case "grep":
		return cmdGrep(args[1:])

それでは、cmdGrep関数を作っていきます。

func cmdGrep(args []string) error {
	if len(args) < 2 {
		return fmt.Errorf("grep usage : grep PATTERN FILE")
	}

	pattern := args[0]
	filename := args[1]

	file, err := os.Open(filename)
	if err != nil {
		return fmt.Errorf("grep : %v", err)
	}
	defer file.Close()

	scanner := bufio.NewScanner(file)
	lineNum := 0

	// 行ごとにスキャン
	for scanner.Scan() {
		lineNum++
		line := scanner.Text()
		// 部分文字列として含まれているかのチェック
		if strings.Contains(line, pattern) {
			fmt.Printf("%d:%s\n", lineNum, line)
		}
	}

	if err := scanner.Err(); err != nil {
		return fmt.Errorf("grep: %v", err)
	}
	return nil
}

順に実装を見ていきます。

if len(args) < 2 {
	return fmt.Errorf("grep usage : grep PATTERN FILE")
}

こちらは引数がうまく受け取れているかの確認をします。
最初の「引数は検索したいパターン」、次の引数は「読み込むファイル名」になります。

その次に、指定したファイルをos.Openで開いていき、

file, err := os.Open(filename)
if err != nil {
	return fmt.Errorf("grep: %v", err)
}
defer file.Close()

開いたファイルは、 bufioのNewScanner関数で読み込んでいきます。
for文で1行ずつ見ていくといった流れです。

scanner := bufio.NewScanner(file)
lineNum := 0

// 行ごとにスキャン
for scanner.Scan() {
	lineNum++
	line := scanner.Text()
	//パターンが含まれていれば表示
	if strings.Contains(line, pattern) {
		fmt.Printf("%d:%s\n", lineNum, line)
	}
}

Scannerはファイルを「行単位で読む」ための便利な構造体で、内部では以下のような仕組みで初期化されます。

func NewScanner(r io.Reader) *Scanner {
	return &Scanner{
		r:            r,                 // 読み込み元
		split:        ScanLines,         // 改行で分割
		maxTokenSize: MaxScanTokenSize,  // 1トークン最大64KB
	}
}

NewScannerはファクトリ関数であり、io.Readerを受け取って初期化済みの *Scanner を返します。
ここでのポイントは split: ScanLines です。


分割方法を決めるSplitFunc

Scanner は「入力をどの単位で区切るか」をSplitFuncという関数型で制御しています。

type SplitFunc func(data []byte, atEOF bool) (advance int, token []byte, err error)

このSplitFuncの部分にScanLinesが設定されていることで「改行して1行ずつ読み込む」といった処理になっています。

他の分割方法

bufioパッケージには、ScanLines以外にも、便利な分割するための関数が用意されています。

関数 動作 用途
ScanWords 単語ごと 単語カウント、単語検索
ScanBytes バイトごと 1バイトずつ処理
ScanRunes ルーン(Unicode文字)ごと 文字単位の処理

このScanner を使って、ファイルの内容を行単位で処理しているのが以下の部分です。

for scanner.Scan() {
		lineNum++
		line := scanner.Text()
		//パターンが含まれていれば表示
		if strings.Contains(line, pattern) {
			fmt.Printf("%d:%s\n", lineNum, line)
		}
}

scanner.Scan()で1行ずつ読み進め、scanner.Text()で該当行を取得するといった感じです。
そして、strings.Contains()を用いることで検索したいパターンがlineに含まれているかどうかを判定しています。

最後に、読み込み中に何らかのI/Oエラーに備えてscanner.Err()でチェックしています。

if err := scanner.Err(); err != nil {
	return fmt.Errorf("grep: %v", err)
}

エラーがあれば scanner.Err()に値が入り、ここで初めてI/Oエラーや読み込み失敗を検知できます。
また、エラーがnilであればファイルの末尾まで正しく読み込めたということで正常終了します。

最後に、実際に試してみましょう。
以下のsample.txtに対してgrepを使ってみようと思います。

sample.txt
Hello, 世界!
GoGo~~~!
grep Go sample.txt
2:GoGo~~~!

Goを含む行が、行番号付きで出力されました。

今回はstrings.Containsで単純な文字列検索のみでしたが、regexpパッケージを使って正規表現を扱えるようにするともっとgrepらしさが出ます!

wc を実装しよう

それではwcを実装していきましょう。
wcでは以下の5つを出力します。

  • 行数
  • 単語数
  • バイト数
  • ファイル名
  • 最終行にそれぞれの合計値(複数のファイルの場合)

まず、実際のwcコマンドで出力をみてみます。
sample.txtを例に考えてみます。

sample.txt
Hello, 世界!
GoGo~~!

こちらを本来のwcコマンドで行数・単語数・バイト数確かめてみると、

wc sample.txt
   2    3    25 sample.txt

行数は 2
単語数は
空白区切りでカウントされているため、Hello,, 世界!, GoGo~~!の3つ
そして、バイト数が

Hello, → 7バイト
世 → 3バイト
界 → 3バイト
! → 3バイト
\n(改行) → 1バイト

1行目は17バイト

GoGo~~! → 7バイト
\n → 1バイト

2行目は8バイト

合計で25バイトであることが確認できました。
このwcコマンド実装するにあたって、この25バイトを正確にカウントすることを目標とします。

それでは、先ほどと同様にswitch文にwcのケースを追加していきます。

	case "wc":
		return cmdWc(args[1:])

そして次のようなcmdWc関数を作成していきます。

func cmdWc(args []string) error {
	if len(args) == 0 {
		return fmt.Errorf("missing file operand")
	}

	var totalLines, totalWords, totalBytes int
	var hasErr bool

	for _, filename := range args {
		file, err := os.Open(filename)
		if err != nil {
			fmt.Fprintf(os.Stderr, "wc: %s: %v\n", filename, err)
			continue
		}

		lines, words, bytes, err := countFile(file)
		file.Close()

		if err != nil {
			fmt.Fprintf(os.Stderr, "wc: %s %v\n", filename, err)
			hasErr = true
			continue
		}

		fmt.Printf("%7d %7d %7d %s\n", lines, words, bytes, filename)

		totalLines += lines
		totalWords += words
		totalBytes += bytes
	}

    // 複数ファイルの場合は合計を表示
	if len(args) > 1 {
		fmt.Printf("%7d %7d %7d total\n", totalLines, totalWords, totalBytes)
	}
	if hasErr {
		return fmt.Errorf("wc: some files could not be read")
	}

	return nil
}

func countFile(file *os.File) (lines, words, bytes int, err error) {
	stat, err := file.Stat()
	if err != nil {
		return 0, 0, 0, err
	}
	bytes = int(stat.Size())

	scanner := bufio.NewScanner(file)
	for scanner.Scan() {
		line := scanner.Text()
		lines++
		words += len(strings.Fields(line))
	}

	if err := scanner.Err(); err != nil {
		return 0, 0, 0, fmt.Errorf("read error: %w", err)
	}

	return
}

こちらの実装では、各ファイルの行数・単語数・バイト数のカウントはcountFile関数に分離させてます。

まず、countFile関数の実装から見ていきます。
bufioのNewScanner関数を呼んで、Scanメソッドで1行ずつ処理をしていきます。

今回のポイントは、
バイト数を正確に取得するにあたってStat()を使っている点です。

stat, err := file.Stat()
bytes = int(stat.Size())

バイト数を数える方法として、以下のような実装も考えられます。

scanner := bufio.NewScanner(file)
for scanner.Scan() {
    line := scanner.Text()
    bytes += len(line) + 1  // 改行分を+1
}

最初、私自身このように実装していたのですが次のような問題がありました。

  • 最終行が改行なしで終わる場合、1バイト多くカウントされてしまうこと
  • UTF-8のマルチバイトを意識する必要があること

file.Stat()を使えば、OSからファイルのメタ情報(サイズ、更新日時、権限など)を取得できます。
Stat()が返すFileInfoSize()メソッドで、ファイルの正確なバイト数がわかります。

stat, err := file.Stat()
bytes = int(stat.Size())  // OSが管理している実際のファイルサイズ

これを使えば、改行の有無やエンコーディングを気にせず、ファイルの正確なサイズが得られます。

行数のカウントに関しては、以下のように

scanner := bufio.NewScanner(file)
for scanner.Scan() {
    lines++
}

bufioパッケージのScannerで1行づつ読み進めていく方法でカウントしています。

単語数のカウントでは、strings.Fields()を使ってカウントしています。
strings.Fields()は、空白で文字列を分割してスライスを返してくれます。

sample.txt
strings.Fields("Hello World")     // ["Hello", "World"] → 2
strings.Fields("Go  is   fun")    // ["Go", "is", "fun"] → 3
strings.Fields("  Hello  ")       // ["Hello"] → 1

連続する空白や前後の空白も調整してくれる便利な関数です。

strings.Filedsの内部では、ASCIIと非ASCII(日本語など)で処理を切り替える実装になっています。

ASCII文字のみの場合

asciiSpaceテーブルで判定を行います。

var asciiSpace = [256]uint8{'\t': 1, '\n': 1, '\v': 1, '\f': 1, '\r': 1, ' ': 1}

スペース・タブ・改行などを空白として認識するようになっています。

日本語などを含む場合

非ASCIIの場合は、以下のFieldsFuncを呼び出し、unicode.IsSpaceを使っています。

return FieldsFunc(s, unicode.IsSpace)

unicode.IsSpaceの実装を見てみると、2段階で判定していることがわかります。

func IsSpace(r rune) bool {
    // Latin1範囲(U+0000~U+00FF)の場合
    if uint32(r) <= MaxLatin1 {
        switch r {
        case '\t', '\n', '\v', '\f', '\r', ' ', 0x85, 0xA0:
            return true
        }
        return false
    }
    // Latin1より大きい文字(日本語など)はUnicodeのWhite_Spaceプロパティで判定
    return isExcludingLatin(White_Space, r)
}

つまり、
ASCII範囲の時は、スペース・タブ・改行など8種類を高速で判定して、それ以外はUnicodeのWhite_Spaceプロパティで全角スペース(U+3000)やその他のUnicode空白文字を判定しています。
この仕組みのおかげで、日本語のコメントがあるGoファイルでも、全角スペースを正しく単語の区切りとして認識できます。

今回は、このシンプルさを活かしてstrings.Fieldsを採用しています。

White_Spaceで定義されているもの

以下のように、各空白文字が定義されています。

var _White_Space = &RangeTable{
	R16: []Range16{
		{0x0009, 0x000d, 1},     // タブ・改行
		{0x0020, 0x0085, 101},   // スペースなど
		{0x00a0, 0x1680, 5600}, // ノーブレークスペースなど
		{0x2000, 0x200a, 1},     // 各種スペース
		{0x2028, 0x2029, 1},   // 行区切り・段落区切り
		{0x202f, 0x205f, 48},
		{0x3000, 0x3000, 1},     // 全角スペース
	},
	LatinOffset: 2,
}
// %7d → 7桁揃え
fmt.Printf("%7d %7d %7d %s\n", lines, words, bytes, filename)
fmt.Printf("%7d %7d %7d total\n", totalLines, totalWords, totalBytes)

これでかなり本物のwcコマンドに近づけたのではないでしょうか。

最後に、実際のwcコマンドと先ほど実装したwcコマンドをそれぞれ実行し、同じ結果値が得られるか確かめます。
以下のファイルに対してwcコマンドを試してみます。

実行してみる

sample1.txtとsample2.txtのファイルを用意します。

sample1.txt
Hello, 世界!
GoGo~~!
こちらはsample1.txtです。
sample2.txt
Hello, World!
GoGo~~!
こちらはsample2.txtです。

wcコマンド出力結果の比較

実際のwc
 wc sample1.txt sample2.txt                          
       3       4      58 sample1.txt
       3       4      57 sample2.txt
       6       8     115 total
先ほど実装したwc
> wc sample1.txt sample2.txt
      3       4      58 sample1.txt
      3       4      57 sample2.txt
      6       8     115 total

同じ出力が得られました!

helpを実装しよう

ラストに、ちょっと雰囲気を出すためにhelpも作ってみましょう。 
UNIXコマンドでは、--help-hオプションでコマンドの使い方を表示させるのが一般的ですが、今回はシンプルに上記で実装したコマンド達と同様にcmdHelp関数を作り、helpコマンドとして実装していきます。
実装はシンプルで、switch文にhelpを追加し、helpが叩かれた時は今回実装したコマンドとその使い方をPrint文で出力するだけです。

func cmdHelp() {
	title := "MY-SHELL(1)"
    width := 30

    // 空白の生成
	padding := strings.Repeat(" ", width - len(title))

	fmt.Printf("%s%sCommands Manual%s%s\n\n", title, padding, padding, title)


	fmt.Println("NAME")
	fmt.Println("     my-shell — simple Unix-like shell implemented in Go")
	fmt.Println()

	fmt.Println("SYNOPSIS")
	fmt.Println("     cat FILE [FILE...]")
	fmt.Println("     ls [DIR or FILE]")
	fmt.Println("     grep PATTERN FILE")
	fmt.Println("     wc FILE")
	fmt.Println("     help")
	fmt.Println("     exit")
	fmt.Println()

	fmt.Println("DESCRIPTION")
	fmt.Println("     These commands provide basic file inspection and text processing.")
}

実際にhelpの叩くと、manコマンドっぽい出力が表現できました!

> help
MY-SHELL(1)          Commands Manual            MY-SHELL(1)

NAME
     my-shell — simple Unix-like shell implemented in Go

SYNOPSIS
     cat FILE [FILE...]
     ls [DIR or FILE]
     grep PATTERN FILE
     wc FILE
     help
     exit

DESCRIPTION
     These commands provide basic file inspection and text processing.



今回実装したコードはこちらで管理しています

さいごに

本記事では、GoでUNIXコマンドを作成するとともに基本的なI/Oに触れてみました。
今回は簡易実装なのであまり深いところまで作ってはいませんが、本記事を通して自分で拡張していくのも面白いかもしれません!

ぜひ、みなさん自身でUNIXコマンドをGoで実装してみてください!

参考資料

https://pkg.go.dev/io

https://pkg.go.dev/os

https://pkg.go.dev/bufio

https://pkg.go.dev/io/fs

https://pkg.go.dev/unicode#White_Space

https://zenn.dev/hsaki/books/golang-io-package/viewer/intro

Discussion