uniq
uniq는 표준입력 혹은 파일 내 텍스트의 중복되는 줄을 없애주는 커맨드라인 툴이다. 이번 챌린지의 목표는 이 uniq 명령어를 구현하는 것이다. 필자의 구현물은 Github에서 확인할 수 있다.
Step 0
챌린지 프로젝트 셋업이다. 테스트용 데이터를 생성하는 과정으로 그대로 따라하면 된다.
Step 1
매개변수로 파일을 받아 중복하는 줄을 제거해서 보여줘야 한다. 필자는 일단 아래와 같이 구현했다.
//main.go
package main
import (
"bufio"
"fmt"
"os"
)
func main() {
args := os.Args
if len(args) < 2 {
fmt.Println("not implemented yet")
os.Exit(1)
}
file, err := os.Open(args[1])
if err != nil {
fmt.Println("Error: ", err)
os.Exit(1)
}
defer file.Close()
u := NewUnique()
result := u.Execute(file)
for _, line := range result {
fmt.Print(line)
}
os.Exit(0)
}
type unique struct {
prev string
}
func NewUnique() *unique {
u := new(unique)
return u
}
func (u *unique) Execute(file *os.File) []string {
result := make([]string, 0)
reader := bufio.NewReader(file)
for {
line, err := reader.ReadString('\n')
if err != nil {
return result
}
if u.prev != line {
result = append(result, line)
u.prev = line
}
}
}
여기서는 특별히 신경쓰거나 어려웠던 부분은 없었다. 각 줄은 reader의 readString으로 개행 문자를 읽어서 구분했다.
Step 2
이번에는 파일 대신 표준 입력을 받을 수 있도록 해야 한다.
//main.go
import (
"bufio"
"errors"
"fmt"
"io"
"os"
)
func main() {
var reader io.Reader = os.Stdin
var writer io.Writer = os.Stdout
var err error
args := os.Args
switch len(args) {
case 1:
case 2:
if args[1] == "-" {
break
}
reader, err = os.Open(args[1])
if err != nil {
fmt.Println("Error: ", err)
os.Exit(1)
}
default:
readers := make([]io.Reader, 0)
for i := 1; i < len(args)-2; i++ {
readers[i-1], err = os.Open(args[i])
if err != nil {
fmt.Println("Error: ", err)
os.Exit(1)
}
}
reader = io.MultiReader(readers...)
writer, err = os.Open(args[len(args)-1])
if errors.Is(err, os.ErrNotExist) {
err = nil
writer, err = os.Create(args[len(args)-1])
}
if err != nil {
fmt.Println("Error: ", err)
os.Exit(1)
}
}
u := NewUnique()
u.Execute(reader, writer)
os.Exit(0)
}
func NewUnique() *unique {
return u
}
func (u *unique) Execute(input io.Reader, output io.Writer) {
reader := bufio.NewReader(input)
for {
line, err := reader.ReadString('\n')
if err != nil {
return
}
if u.prev != line {
output.Write([]byte(line))
u.prev = line
}
}
}
writer와 reader을 변수화 시킨 후 파일을 받을 경우 파일을 열어서 reader에 넣었다. 이 역시 기존 Coding Challenge들과 크게 다른 점이 없었으므로 이야기 할만한 것이 없다.
Step 3
-c 옵션이 붙는 경우 uniq로 분류된 줄 왼쪽에 중복되었던 줄 수를 출력하도록 구현해야 한다. 이 시점에서 옵션에 따라 행동을 다르게 하기 위해 unique 패키지를 만들어서 그 곳에서 구현했다.
//main.go
package main
import (
"errors"
"flag"
"fmt"
"io"
"os"
"github.com/simp7/guniq/unique"
)
type Unique interface {
Execute(input io.Reader, output io.Writer)
}
func main() {
var reader io.Reader = os.Stdin
var writer io.Writer = os.Stdout
var err error
var u Unique = unique.Standard()
counting := flag.Bool("c", false, "count")
flag.Parse()
if *counting {
u = unique.Counting()
}
args := flag.Args()
switch len(args) {
case 0:
case 1:
if args[0] == "-" {
break
}
reader, err = os.Open(args[0])
if err != nil {
fmt.Println("Error: ", err)
os.Exit(0)
}
default:
readers := make([]io.Reader, 0)
for i := 0; i < len(args)-1; i++ {
readers[i], err = os.Open(args[i])
if err != nil {
fmt.Println("Error: ", err)
os.Exit(1)
}
}
reader = io.MultiReader(readers...)
writer, err = os.Open(args[len(args)-1])
if errors.Is(err, os.ErrNotExist) {
err = nil
writer, err = os.Create(args[len(args)-1])
}
if err != nil {
fmt.Println("Error: ", err)
os.Exit(1)
}
}
...
}
//unique/standard.go
package unique
import (
"bufio"
"io"
)
type standard struct {
prev string
}
func Standard() *standard {
u := new(standard)
return u
}
func (s *standard) Execute(input io.Reader, output io.Writer) {
reader := bufio.NewReader(input)
for {
line, err := reader.ReadString('\n')
if err != nil {
return
}
if s.prev != line {
output.Write([]byte(line))
s.prev = line
}
}
}
//unique/counting.go
package unique
import (
"bufio"
"fmt"
"io"
)
type counting struct {
prev string
}
func Counting() *counting {
c := new(counting)
return c
}
func (c *counting) Execute(input io.Reader, output io.Writer) {
reader := bufio.NewReader(input)
count := 0
line, err := reader.ReadString('\n')
if err != nil {
return
}
c.prev = line
for {
count++
line, err = reader.ReadString('\n')
if err != nil {
formatted := fmt.Sprintf("%d %s", count, c.prev)
output.Write([]byte(formatted))
return
}
if c.prev == line {
continue
}
formatted := fmt.Sprintf("%d %s", count, c.prev)
output.Write([]byte(formatted))
c.prev = line
count = 0
}
}
uniq의 작동을 Unique 인터페이스로 정의했고 플래그에 따라 다른 구조체가 할당되도록 설계했다.
Step 4
-d 옵션이 붙는 경우 겹치는 줄만 출력하도록 구현해야 한다.
//unique/repeated
package unique
import (
"bufio"
"io"
)
type repeated struct {
prev string
}
func Repeated() *repeated {
r := new(repeated)
return r
}
func (r *repeated) Execute(input io.Reader, output io.Writer) {
printed := false
reader := bufio.NewReader(input)
line, err := reader.ReadString('\n')
if err != nil {
return
}
r.prev = line
for {
line, err := reader.ReadString('\n')
if err != nil {
return
}
if r.prev != line {
r.prev = line
printed = false
continue
}
if !printed {
output.Write([]byte(line))
printed = true
}
}
}
Step 5
-u 옵션이 붙은 경우 겹치지 않은 줄만 출력하도록 해야 한다.
//unique/singular.go
package unique
import (
"bufio"
"io"
)
type singular struct {
prev string
}
func Singular() *singular {
r := new(singular)
return r
}
func (s *singular) Execute(input io.Reader, output io.Writer) {
isDuplicate := false
reader := bufio.NewReader(input)
line, err := reader.ReadString('\n')
if err != nil {
return
}
s.prev = line
for {
line, err := reader.ReadString('\n')
if err != nil {
output.Write([]byte(s.prev))
return
}
if s.prev != line {
if !isDuplicate {
output.Write([]byte(s.prev))
}
s.prev = line
isDuplicate = false
continue
}
isDuplicate = true
}
}
//main.go
...
singular := flag.Bool("u", false, "print only unique lines")
flag.Parse()
...
if *singular {
u = unique.Singular()
}
...
Step 6
-c와 -u/-d이 서로 조합 가능하도록 구현해야 한다. 해당 Step에서 test case를 추가하면서 여러가지 세세한 부분들을 수정했다.
//main.go
...
if *singular {
u = unique.Singular(*counting)
}
if *repeated {
u = unique.Repeated(*counting)
}
...
//unique/counting.go
func (c *counting) Execute(input io.Reader, output io.Writer) {
scanner := bufio.NewScanner(input)
scanner.Scan()
c.prev = scanner.Text()
count := 1
for scanner.Scan() {
line := scanner.Text()
if c.prev == line {
count++
continue
}
formatted := fmt.Sprintf("%4d %s\n", count, c.prev)
output.Write([]byte(formatted))
c.prev = line
count = 1
}
formatted := fmt.Sprintf("%4d %s\n", count, c.prev)
output.Write([]byte(formatted))
//unique/repeated.go
import (
"bufio"
"fmt"
"io"
)
type repeated struct {
prev string
isCounting bool
}
func Repeated(isCounting bool) *repeated {
r := new(repeated)
r.isCounting = isCounting
return r
}
func (r *repeated) print(output io.Writer, count int) {
formatted := r.prev + "\n"
if r.isCounting {
formatted = fmt.Sprintf("%4d %s", count, formatted)
}
output.Write([]byte(formatted))
}
func (r *repeated) Execute(input io.Reader, output io.Writer) {
scanner := bufio.NewScanner(input)
scanner.Scan()
line := scanner.Text()
r.prev = line
count := 1
for scanner.Scan() {
line := scanner.Text()
if r.prev != line {
if count > 1 {
r.print(output, count)
}
r.prev = line
count = 1
continue
}
count++
}
if count > 1 {
r.print(output, count)
}
}
//unique/singular.go
type singular struct {
prev string
isCounting bool
}
func Singular(isCounting bool) *singular {
r := new(singular)
r.isCounting = isCounting
return r
}
func (s *singular) print(output io.Writer) {
formatted := s.prev + "\n"
if s.isCounting {
formatted = " 1 " + formatted
}
output.Write([]byte(formatted))
}
func (s *singular) Execute(input io.Reader, output io.Writer) {
isDuplicate := false
scanner := bufio.NewScanner(input)
scanner.Scan()
s.prev = scanner.Text()
for scanner.Scan() {
line := scanner.Text()
if s.prev != line {
if !isDuplicate {
s.print(output)
}
s.prev = line
isDuplicate = false
} else {
isDuplicate = true
}
s.prev = line
}
if !isDuplicate {
s.print(output)
}
}
//unique/standard.go
func (s *standard) Execute(input io.Reader, output io.Writer) {
scanner := bufio.NewScanner(input)
scanner.Scan()
line := scanner.Text()
output.Write([]byte(line + "\n"))
s.prev = line
for scanner.Scan() {
line = scanner.Text()
if s.prev != line {
output.Write([]byte(line + "\n"))
s.prev = line
}
}
...
Step 6의 명세를 보고 깨달은 바부터 설명하자면 모든 옵션이 서로 베타적이지 않다는 것이다. 즉, 일부 옵션(-c와 -u/-d)은 서로 합쳐져 작동되어야 하기 때문에 서로를 덮어쓰여저선 안된다는 것이다. 그래서 일단 -u와 -d 옵션 구현체의 생성자에 -c 추가 여부를 매개변수로 받도록 했다. 다만 좀 더 엄밀하게 하기 위해서는 별도의 인터페이스를 만들어서 출력할 줄을 필터링하는 인터페이스 => 출력 형식을 정하는 인터페이스 형태로 데이터를 전달하는 구조가 제일 적합해보인다.
한편 test case를 추가하다보니 깨달은 것인데, 기존의 reader.ReadString('\n')에는 큰 문제가 한가지 있다. 파일의 끝에 개행 문자가 포함되지 않을 경우 해당 줄을 제대로 처리하지 못한다는 것이다. 물론 해당 함수로도 파일의 끝 처리를 위한 로직을 추가하면 되지만 줄 단위로 읽는 데에는 scanner의 scan 함수를 사용하는 것이 일반적이다. 단, 예외적인 상황(한 줄의 길이가 지나치게 길어 scanner의 버퍼 크기를 넘거나 에러 처리가 필요한 경우)까지 고려하면 결국 reader를 쓰는게 낫다고 한다. 필자는 그런 상황까지 고려하진 않았기 때문에 직관성을 위해 scanner을 쓰도록 코드를 바꿨다.
알게된 것
- Go언어에서 줄 단위로 읽을 때에는 reader를 이용해 ReadString('\n')을 호출하는 것보다 scanner를 이용해 Scan() 및 Text()를 호출해 읽는 편이 파일의 끝을 처리할 때 더욱 편리하다.
보완점
- man uniq를 보면 구현하지 않은 플래그가 몇 존재한다. 추가 과제로 삼기 좋아보인다.
- 출력 형식을 설정하는 인터페이스와 어떤 줄을 보여줄지 필터링하는 인터페이스가 분리될 수 있다고 느꼈다.
마치며
사실 이번 챌린지는 마지막에 가서 고칠 점들이 눈에 보여서 아쉬웠다. 기회가 된다면 보완점에서 언급한 부분을 포함하여 개선하여 글로 올려볼 생각이다.