wc
wc는 표준 입력 혹은 파일 내 데이터의 줄 수, 단어 수, 바이트 수를 보여주는 커맨드라인 툴이다. 이번 챌린지의 목표는 이 wc를 구현하는 것이다. 필자의 구현 결과물은 Github에 올려놓았다.
들어가기에 앞서
사실 이번 글은 이전 글에서 보였던 Step대로 차근차근 구현해가는 방식이 아니다. 사실 wc를 cat보다 먼저 구현했는데, 그 당시에는 맛보기로 했던 지라 Step을 신경쓰지 않고 구현(및 커밋)했다. 이러한 이유로 이번 글은 구현 과정 대신 각 코드 부분들에 대한 해석 위주로 풀어갈 생각이다.
gwc.go
gwc.go는 메인 함수 파트로 매개변수 및 플래그를 파싱하여 입력 방식 및 단위를 정하고 결과를 얻어내는 함수이다.
//gwc.go
package main
import (
"flag"
"fmt"
"io"
"os"
"github.com/simp7/gwc/counter"
)
type Counter interface {
Count(text []byte) int
}
func processAll(files []string, counters ...Counter) {
if len(files) == 0 {
counts := process(os.Stdin, counters...)
fmt.Println(formatCounts(counts))
return
}
total := make([]int, len(counters))
for _, fileName := range files {
file, err := os.OpenFile(fileName, os.O_RDONLY, os.ModePerm)
if err != nil {
fmt.Println("file " + fileName + " is not valid")
return
}
counts := process(file, counters...)
for i := range total {
total[i] += counts[i]
}
fmt.Println(formatCounts(counts), fileName)
}
if len(files) > 1 {
fmt.Println(formatCounts(total), "total")
}
}
func process(r io.Reader, counters ...Counter) []int {
text, err := io.ReadAll(r)
if err != nil {
fmt.Println(err)
}
result := make([]int, len(counters))
for i, c := range counters {
result[i] = c.Count(text)
}
return result
}
func formatCounts(counts []int) string {
output := ""
for _, count := range counts {
output += fmt.Sprintf("%8d", count)
}
return output
}
func main() {
charMode := ""
isLine := flag.Bool("l", false, "get number of lines")
isWord := flag.Bool("w", false, "get number of words")
flag.BoolFunc("c", "get number of bytes", func(string) error { charMode = "c"; return nil })
flag.BoolFunc("m", "get number of characters", func(string) error { charMode = "m"; return nil })
flag.Parse()
counters := make([]Counter, 0)
if *isLine {
counters = append(counters, counter.Line())
}
if *isWord {
counters = append(counters, counter.Word())
}
switch charMode {
case "c":
counters = append(counters, counter.Byte())
case "m":
counters = append(counters, counter.Character())
}
if len(counters) == 0 {
processAll(flag.Args(), counter.Line(), counter.Word(), counter.Byte())
} else {
processAll(flag.Args(), counters...)
}
}
Counter는 텍스트 데이터를 받아서 선택한 단위(글자, 단어, 줄 수)의 갯수를 반환하는 인터페이스이다. 해당 인터페이스의 구현체는 이후 counter 폴더 내에서 구현할 예정이다.
processAll은 파일 목록과 Counter 구현체를 받아 사용자가 원하는 결과를 출력하는 역할을 한다. 파일 목록이 비어있는 경우 명세대로 표준 입력을 받으며 다수의 파일과 다수의 Counter에 대응하도록 구현했다. 또한 2개 이상의 파일을 받을 경우 총계를 볼 수 있도록 구현했다.
main 함수는 플래그와 파일 이름을 파싱하여 processAll에 전달하도록 구현했다. 여기서 -c와 -m가 이질적으로 구현되어 있는데 이는 wc의 명세를 최대한 지키기 위함이었다. 터미널에서 man wc를 치고 -c와 -m 항목의 설명을 보면 다음과 같음을 확인할 수 있다.
// -c
The number of bytes in each input file is written to the standard output. This will cancel out any prior usage of the -m option.
// -m
The number of characters in each input file is written to the standard output. If the current locale does not support multibyte characters, this is equivalent to the -c option. This will cancel out any prior usage of the -c option.
각 설명의 마지막 줄이 핵심으로 -m옵션은 -c옵션을 이전 서로의 옵션을 취소할 수 있다. 이렇게 순서에 따라 덮어쓰는 방법을 생각해 보았고 boolFunc를 통해 공통의 변수에 쓰는 방식으로 구현하는 방법을 선택했다.
counter/*.go
다음은 각 Counter의 구현체들이다.
//counter/byte.go
package counter
type byteCounter struct {
}
func Byte() *byteCounter {
return &byteCounter{}
}
func (b *byteCounter) Count(text []byte) int {
return len(text)
}
//counter/character.go
package counter
type characterCounter struct {
}
func Character() *characterCounter {
return &characterCounter{}
}
func (c *characterCounter) Count(text []byte) int {
return len([]rune(string(text)))
}
//counter/line.go
package counter
type lineCounter struct {
}
func Line() *lineCounter {
return &lineCounter{}
}
func (l *lineCounter) Count(text []byte) int {
count := 0
for _, char := range text {
if char == '\n' {
count++
}
}
return count
}
//counter/word.go
package counter
import (
"strings"
"unicode"
)
type wordCounter struct {
}
func Word() *wordCounter {
return &wordCounter{}
}
func (w *wordCounter) Count(text []byte) int {
trimmed := strings.TrimSpace(string(text))
count := 0
isSpace := true
for _, char := range trimmed {
if unicode.IsSpace(char) {
isSpace = true
} else {
if isSpace {
count++
}
isSpace = false
}
}
return count
}
//counter/counter_test.go
package counter
import (
"testing"
)
func Test_byteCounter_Count(t *testing.T) {
type args struct {
text []byte
}
tests := []struct {
name string
args args
want int
}{
{name: "normal", args: args{text: []byte("hello world")}, want: 11},
{name: "empty", args: args{text: []byte("")}, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
b := &byteCounter{}
if got := b.Count(tt.args.text); got != tt.want {
t.Errorf("Count() = %v, want %v", got, tt.want)
}
})
}
}
func Test_characterCounter_Count(t *testing.T) {
type args struct {
text []byte
}
tests := []struct {
name string
args args
want int
}{
{name: "normal", args: args{text: []byte("hello world")}, want: 11},
{name: "hangul", args: args{text: []byte("안녕 세상아")}, want: 6},
{name: "mixed", args: args{text: []byte("Hello, 世界")}, want: 9},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &characterCounter{}
if got := c.Count(tt.args.text); got != tt.want {
t.Errorf("Count() = %v, want %v", got, tt.want)
}
})
}
}
func Test_lineCounter_Count(t *testing.T) {
type args struct {
text []byte
}
tests := []struct {
name string
args args
want int
}{
{name: "normal", args: args{text: []byte("hello world")}, want: 0},
{name: "manyLines", args: args{text: []byte("hel\nlo,\n world")}, want: 2},
{name: "empty", args: args{text: []byte("")}, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
l := &lineCounter{}
if got := l.Count(tt.args.text); got != tt.want {
t.Errorf("Count() = %v, want %v", got, tt.want)
}
})
}
}
func Test_wordCounter_Count(t *testing.T) {
type args struct {
text []byte
}
tests := []struct {
name string
args args
want int
}{
{name: "space", args: args{text: []byte("hello world")}, want: 2},
{name: "tab", args: args{text: []byte("hello\tworld")}, want: 2},
{name: "lineBreak", args: args{text: []byte("hello\nworld")}, want: 2},
{name: "justSpace", args: args{text: []byte(" \n\t \t \n")}, want: 0},
{name: "manySpaces", args: args{text: []byte("\n\t\n H \n E\tL L O\t\n, World")}, want: 7},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := &wordCounter{}
if got := w.Count(tt.args.text); got != tt.want {
t.Errorf("Count() = %v, want %v", got, tt.want)
}
})
}
}
위 네 파일의 구조체는 각자의 단위에 맞춰 Counter 인터페이스를 충족하여 구현했다. 특기할 만한 부분은 byteCounter와 characterCounter의 차이이다.
byteCounter는 말 그대로 입력 값의 byte 수를 세는 구조체이다. 구현 상에서도 []byte형으로 받은 데이터의 길이 값을 반환하는 것이 전부이다. 반면 characterCounter는 입력 값의 글자수를 세는 구조체이다. 글자 수와 byte 갯수는 일치하지 않으며, 한 글자는 1개 이상의 바이트로 구성된다.
알파벳, 숫자, 일반적인 문장 부호의 경우 1byte만 써서 위의 두 카운터가 동일한 결과를 반환한다. 이렇게 1byte(엄밀히 말하면 7bit)만으로 표현되도록 만들어진 문자들은 ASCII 코드라는 형식으로 표현 방식이 정립되었다.
하지만 한글과 같은 알파벳 외의 문자나 이모지, ¿와 같은 특정 언어에서만 쓰는 문장 부호의 경우 1byte 안에 모든 경우의 수를 담을 수 없었다. 이에 모든 문자를 표현하기 위해 유니코드라는 새로운 체계가 나타나게 되었다. 이 중 가장 널리 알려져 있으며 Go언어에서 채택한 인코딩 방식인 UTF-8의 경우 글자 하나를 표현하는 데에 1~4byte를 쓴다.
Go언어가 string을 표현하는 방식으로 []byte와 []rune이 있다. 이 중 []rune이 바로 UTF-8 방식으로 글자를 보는 방식으로 사람이 '글자'라고 인식하는 단위로 추출하여 배열이 만들어진다. 즉 characterCounter의 Count 함수의 반환값
return len([]rune(string(text)))
의 동작을 순서대로 설명하자면 다음과 같다.
- []byte 형식의 text를 string 형으로 바꾼다.
- string 형의 데이터를 []rune형, 즉 글자의 배열로 바꾼다.
- 글자 배열의 길이, 즉 글자의 개수를 반환한다.
UTF-8의 경우 ASCII와 호환이 되기 때문에 일반적인 영어로만 된 문서는 글자의 byte가 모두 1이므로 byteCounter와 characterCounter에서 Count를 호출한 결과가 비슷하다. 하지만 다른 문자로 된 문서(한글 등)의 경우 결과가 확연히 달라지는 것을 확인할 수 있다.(예를 들어, 한글은 글자당 3byte이므로 띄어 쓰기, 문장 부호 등을 고려하면 2.5배 정도 차이가 난다.) 다음은 메밀꽃 필 무렵을 wc 툴로 세어본 결과이다.
wc -c 메밀꽃_필_무렵.txt
20276 메밀꽃_필_무렵.txt
wc -m 메밀꽃_필_무렵.txt
8628 메밀꽃_필_무렵.txt
알게된 것
- boolFunc를 통해 플래그를 받는 방법이 있으며 이는 다른 플래그와의 순서에 따라 로직이 바뀔 때 특히 유용하다.
- Go언어에서 string형은 []byte형과 []rune형으로 변환이 가능하다. []byte형은 데이터 단위로 나눈 것이고 []rune형은 글자 단위(UTF-8)로 나눈 것이다.
보완점
- man cat을 했을 때 -L라는 플래그가 있었다. 줄 단위로 나눴을 때 byte(-m 플래그와 쓸 경우 글자)의 최대값을 표현하는 플래그로 도전해 볼만하다. 이 때 total값은 기존 옵션과 다르게 모든 입력의 결과값 중 가장 큰 값을 표현해야 하므로 total값을 구하는 로직 역시 수정할 필요가 있어 보인다.
- 고루틴을 활용해 각 파일/단위마다 실행을 하여 최적화 할 수 있어 보인다. 여기서 더 나아가 파일을 한번에 읽는 것이 아니라 일정 부분씩 읽어가며 고루틴을 통해 처리해 나가면서 다음 부분을 읽는 방식으로 구현할 수도 있어 보인다.
마치며
필자가 생각하는 이 챌린지의 핵심은 -c와 -m의 차이점을 인식하고 구현하는 것이다. 특히 rune에 대해서는 문자열 표현에 쓰인다는 것만 들어보았지 정확히 어떤 것인지는 몰랐는데 이번 기회로 확실히 알 수 있게 되었다.