Coding Challenge란?
Coding Challenge란 기존 툴이나 프로그램을 본인의 방식대로 구현하는 챌린지이다.
클론 코딩과의 차이점
일반적인 클론 코딩은 웹페이지가 주요 타겟인 반면 이 챌린지는 개발 툴, 프로토콜, 혹은 간단한 GUI 프로그램이 주요 타겟이다. 클론 코딩에 비해 프로그래밍 언어나 기술적인 제약이 없기 때문에 새로운 언어를 익히거나 기존에 쓰던 언어로 구현 능력을 기르기에 적합하다. 또한 단계별 구현 목표가 명시되어 있기 때문에 클론 코딩에 비해 더 정량적인 평가가 가능하다. 더 자세한 소개는 해당 사이트에서 살펴볼 수 있다.
Coding Challenge 과제 순서
물론 위 사이트의 챌린지 목록 순서대로 해도 상관 없다. 하지만 목록을 쭉 살펴보면 느끼겠지만 난이도가 뒤죽박죽 섞여있기 때문에 입문자가 느끼기에는 어려울 수 있다.
필자가 생각하기에는 Coding Challenges For Junior Developers의 목록 순서대로 구현하는 것이 좋아보인다. 공식적으로 추천하는 입문 순서이기도 하고 명세가 명확하며 구현 난이도도 어렵지 않아 보인다.(아직 진행중이어서 확답할 수는 없다.)
cat
cat은 파일(혹은 표준 입력)을 보여주는 간단한 커맨드라인 툴이다. 이번 챌린지의 목표는 이 cat을 구현하는 것이다. 필자의 구현 결과물은 Github에 올려놓았다.
구현 과정
Step 0
챌린지 프로젝트 셋업이다. 테스트용 데이터를 생성하는 과정으로 그대로 따라하면 된다.
Step 1
매개변수로 받은 파일의 내용을 출력하는 기능을 구현해야 한다. 필자는 아래 Step 2와 같이 묶어서 구현했으므로 추가 설명을 하지는 않겠다.
Step 2
매개변수에 "-"이 포함되어 있거나 매개변수가 없는 경우 파일 대신 표준 입력을 받아 출력하는 기능을 구현해야 한다.
필자는 다음과 같이 구현했다.
//gcat.go
package main
import (
"flag"
"fmt"
"io"
"os"
"github.com/simp7/gcat/formatter"
)
func exitWithError(err error) {
fmt.Printf("Error occured: %s\n", err)
os.Exit(1)
}
func main() {
var input io.Reader
var output io.Writer
var f Formatter
var line []byte
var err error
output = os.Stdout
args := os.Args
isNumbered := flag.Bool("n", false, "Number the output lines, starting at 1.")
isNonBlankNumbered := flag.Bool("b", false, "Number the non-blank output lines, starting at 1.")
if len(args) == 1 {
input = os.Stdin
} else {
if args[1] == "-" {
input = os.Stdin
} else {
file, err := os.Open(args[1])
if err != nil {
exitWithError(err)
return
}
input = file
}
}
if *isNumbered {
} else if *isNonBlankNumbered {
} else {
f = formatter.Standard(input)
}
for {
if line, err = f.ReadLine(); err != nil {
break
}
output.Write(append(line, '\n'))
}
if err != io.EOF {
exitWithError(err)
return
}
}
//interface.go
package main
type Formatter interface {
ReadLine() ([]byte, error)
}
//formatter/standard.go
package formatter
import (
"bufio"
"io"
)
type standard struct {
reader *bufio.Reader
}
func Standard(input io.Reader) *standard {
s := new(standard)
s.reader = bufio.NewReader(input)
return s
}
func (s *standard) ReadLine() ([]byte, error) {
line, _, err := s.reader.ReadLine()
return line, err
}
input과 output을 각각 io.Writer과 io.Reader로 받아서 입력/출력 소스에 관계 없이 동작하게끔 구현했다. 실제로 Step1과 Step2에서 각각 파일과 표준 입력으로 입력을 받았지만 io.Reader 자리에 다른 객체를 할당하고 같은 함수를 사용할 수 있었다.
Formatter라는 새로운 인터페이스를 구현하고 이를 만족하는 구조체를 구현했다. 이후에 나올 Step에서 출력을 달라지게 하기 위해 미리 구현해놓은 것이다. 하지만 이는 현재 단계에서는 오버엔지니어링이다.
잠시 다른 이야기를 하자면 각 Step에는 해당 Step까지만 생각하고 구현하는 것이 더 공부가 된다고 생각한다. 일반적으로 우리는 처음부터 모든 요구사항을 알 수 없다. 추가적인 요구사항이 생기면 그것에 맞춰서 구현하게 된다. 현업을 생각해봐도 처음부터 완벽하게 만드는 것보다 고쳐가면서 구현해나가는 경우가 많다. 즉, 작게 시작해서 요구사항에 맞게 발전시켜나가는 것이 일반적인 프로그래밍의 흐름이다. 이러한 관점에서 볼 때 이후 Step까지 미리 생각하는 것보다 당장의 Step에 집중하면서 구현해 나가는 것이 여러모로 공부가 되지 않을까 생각한다.
마지막으로 위 코드 중 플래그를 받는 부분에는 오류가 있는데, go언어에서는 플래그를 정의한 이후 반드시 flag.Parse()를 호출해야 한다. 호출하지 않은 경우 입력된 플래그 모두 플래그가 아닌 매개변수로 취급된다.
Step 3
매개변수로 받은 모든 파일(및 표준 입력)을 출력하도록 구현해야 한다. 재밌는 사실은 매개변수 순서대로 출력되는데 표준입력도 예외가 아니어서 매개변수 중간에 -가 오면 해당 순서에 표준 입력을 받게 된다는 것이다.
//gcat.go
//...
if len(args) == 1 {
input = os.Stdin
} else {
files := args[1:]
readers := make([]io.Reader, len(files))
for index, name := range files {
if name == "-" {
readers[index] = os.Stdin
} else {
file, err := os.Open(name)
if err != nil {
exitWithError(err)
return
}
readers[index] = file
}
}
input = io.MultiReader(readers...)
}
if *isNumbered {
} else if *isNonBlankNumbered {
} else {
f = formatter.Standard(input)
}
//...
io.MultiReader를 활용하여 reader 인터페이스는 그대로 쓰면서 여러 reader에도 대응할 수 있게끔 구현했다.
Step 4
플래그 -n을 받을 경우 모든 줄 앞에 번호를 매기도록 구현해야 한다. 위에서 Formatter를 미리 구현한 이유이다.
//formatter/numbered.go
package formatter
import (
"bufio"
"fmt"
"io"
)
type numbered struct {
reader bufio.Reader
lineNumber int
}
func Numbered(input io.Reader) *numbered {
n := new(numbered)
n.reader = *bufio.NewReader(input)
n.lineNumber = 1
return n
}
func (n *numbered) ReadLine() ([]byte, error) {
line, _, err := n.reader.ReadLine()
if err != nil {
return nil, err
}
text := fmt.Sprintf("%6d\t%s", n.lineNumber, string(line))
n.lineNumber++
return []byte(text), nil
}
//gcat.go
//...
output = os.Stdout
isNumbered := flag.Bool("n", false, "Number the output lines, starting at 1.")
isNonBlankNumbered := flag.Bool("b", false, "Number the non-blank output lines, starting at 1.")
flag.Parse()
files := flag.Args()
if len(files) == 0 {
input = os.Stdin
} else {
readers := make([]io.Reader, len(files))
for index, name := range files {
if name == "-" {
readers[index] = os.Stdin
} else {
file, err := os.Open(name)
if err != nil {
exitWithError(err)
return
}
readers[index] = file
}
}
input = io.MultiReader(readers...)
}
if *isNumbered {
f = formatter.Numbered(input)
} else if *isNonBlankNumbered {
} else {
f = formatter.Standard(input)
}
//...
Formatter를 만족하는 numbered를 구현하여 입력 소스와 독립적으로 출력 형식을 정할 수 있도록 구현했다.
새롭게 알았던 사실이 있다면 형식 지정자를 통해 우측 정렬이 가능하다는 것이었다. "%6d\t%s"의 %6d가 바로 그것으로, 6자리 기준으로 우측정렬을 하라는 뜻이다. 우측 정렬 사용법은 다음과 같다.
%(차지할 글자 수)(형식 지정자 타입)
Step 5
빈 줄의 경우 카운트하지 않도록 구현하면 된다. 이 "빈 줄"에는 탭이나 공백문자 등도 포함하지 않는 줄이므로 구현은 오히려 쉬웠다.
//formatter/numbered.go
type nonBlankNumbered struct {
reader bufio.Reader
lineNumber int
}
func NonBlankNumbered(input io.Reader) *nonBlankNumbered {
n := new(nonBlankNumbered)
n.reader = *bufio.NewReader(input)
n.lineNumber = 1
return n
}
func (n *nonBlankNumbered) ReadLine() ([]byte, error) {
line, _, err := n.reader.ReadLine()
if err != nil {
return nil, err
}
if len(line) == 0 {
return line, nil
}
text := fmt.Sprintf("%6d\t%s", n.lineNumber, string(line))
n.lineNumber++
return []byte(text), nil
}
//gcat.go
//...
if *isNonBlankNumbered {
f = formatter.NonBlankNumbered(input)
} else if *isNumbered {
f = formatter.Numbered(input)
} else {
f = formatter.Standard(input)
}
//...
Step 5와 구현 과정이 동일하다.
알게된 것
- Go언어에서 커맨드라인 플래그는 정의 후 flag.Parse()를 호출해야 받을 수 있다.
- 형식 지정자를 통해 우측 정렬이 가능하다.
보완점
- man cat을 입력하고 명세를 보면 구현하지 않은 플래그가 몇 존재한다. 추가 과제로 삼기 좋아보인다.
- 메인 함수가 조금 길다. 여러 개의 함수로 쪼갤 수 있어 보인다.
마치며
이 챌린지는 Coding Challenge의 첫번째 챌린지인 만큼 직관적이었다. 표준 입출력 및 파일 출력에 대해 다루는 만큼 프로그래밍 언어 익히기에도 좋아 보인다. 만약 해당 챌린지에 흥미가 생겼다면 굳이 Go언어가 아니어도 되니까 자신 있는 언어로 도전해보는 것을 권한다.