- 출처: https://quii.gitbook.io/learn-go-with-tests/go-fundamentals/maps
- 개요
array와 slice에서는 데이터를 순차적으로 저장한다. go에서는 key를 기반으로 데이터를 저장할 수 있는 map을 제공한다. dictionary를 만들어 보면서 map에 대해 알아보도록 하자.
- map
package main
import "testing"
func TestSearch(t *testing.T) {
dictionary := map[string]string{"test": "this is just a test"}
got := Search(dictionary, "test")
want := "this is just a test"
if got != want {
t.Errorf("got %q want %q given, %q", got, want, "test")
}
}
map을 선언할때에는 map으로 선언한다. 그 뒤에 [] 안에는 key형을 정의해주고 [] 뒤에는 value형을 정의한다. value형은 제한이 없지만 key형에는 제약이 있는데 비교가 가능해야 한다는것이다. key를 비교해야 저장한 데이터인지 신규 데이터인지 알 수 있기 때문에 당연한 얘기라 할 수 있다.
위의 코드를 수행하면 Search를 정의하라는 메시지가 나올것이다.
package main
func Search(dictionary map[string]string, word string) string {
return ""
}
.........
func Search(dictionary map[string]string, word string) string {
return dictionary[word]
}
처음에는 Compile만 되는상태로 go test를 수행하여 fail이 잘되는지 확인한 후, 제대로 동작하는 코드를 작성해준다. 이상없이 코드를 작성했다면 test가 통과할것이므로 리팩토링할 사항이 있는지 찾아보자.
got과 want 비교 부분은 helper 메소드로 추출해주면 더 의도가 명확해지고 재사용성이 좋아지므로 helper메소드로 추출해보도록 하자.
func TestSearch(t *testing.T) {
dictionary := map[string]string{"test": "this is just a test"}
got := Search(dictionary, "test")
want := "this is just a test"
assertStrings(t, got, want)
}
func assertStrings(t testing.TB, got, want string) {
t.Helper()
if got != want {
t.Errorf("got %q want %q", got, want)
}
}
Dictionary의 사용성을 높이기 위해 map에 기반한 새로운 형을 정의하고 Search 메소드도 정의해보자. 이 요구사항을 만족했다고 가정한 후 test코드를 먼저 작성해보면 아래와 같다.
func TestSearch(t *testing.T) {
dictionary := Dictionary{"test": "this is just a test"}
got := dictionary.Search("test")
want := "this is just a test"
assertStrings(t, got, want)
}
이제 Dictionary와 Search메소드를 만들어보자.
type Dictionary map[string]string
func (d Dictionary) Search(word string) string {
return d[word]
}
map에 기반한 새로운 Dictionary형을 정의하였다. 또한 이렇게 새로운 형을 정의하면서 해당 형을 receiver로 하여 메소드도 정의할 수 있게 되었다.
사전의 기본적인 검색기능을 구현하는게 어려운건 아니다. 다만 한 가지 생각해볼점이 있는데 찾으려고 하는 word가 없으면 어떻게 되느냐는 점이다.
해당 word가 없으면 빈값이 반환될것이다. 이런 상황에서도 프로그램은 계속 동작하며, 대세에 큰 영향은 없지만 Search 메소드가 word가 없다는 정보를 반환하도록 하는게 더 좋은 접근방법이 될 수 있다. 또 이렇게 하면 해당 word가 없는 상태인지, word는 있는데 정의가 되지 않은 상태인지를 명확히 알 수 있다.
func TestSearch(t *testing.T) {
dictionary := Dictionary{"test": "this is just a test"}
t.Run("known word", func(t *testing.T) {
got, _ := dictionary.Search("test")
want := "this is just a test"
assertStrings(t, got, want)
})
t.Run("unknown word", func(t *testing.T) {
_, err := dictionary.Search("unknown")
want := "could not find the word you were looking for"
if err == nil {
t.Fatal("expected to get an error.")
}
assertStrings(t, err.Error(), want)
})
}
Go에서는 보통 이런경우에 2번째 인자로 error를 반환한다. error타입은 .Error() 메소드를 호출하면 문자열로 변환이 가능하여 assertStrings를 이용할 수 있게해준다. 또 if 문으로 nil 검사를 선제적으로 해주어서 err가 nil인 경우 assertStrings 호출시 panic에 빠지지 않도록 할 수 있다.
현재 Search에서 key에 대한 정의만 반환하고 있으므로 go test를 실행하면 반환개수가 맞지 않다는 에러를 출력할것이다.
func (d Dictionary) Search(word string) (string, error) {
return d[word], nil
}
...........
func (d Dictionary) Search(word string) (string, error) {
definition, ok := d[word]
if !ok {
return "", errors.New("could not find the word you were looking for")
}
return definition, nil
}
Compile만 통과하도록 코드를 작성하여 assert에서 fail이 제대로 동작하는지 확인해준 후 test가 pass하도록 코드를 수정해준다. map에서 2번째 인자까지 반환받으면 정의뿐만 아니라 해당 key의 존재여부를 알 수 있다. 위의 코드에서는 ok로 key가 map에 존재하는지 여부를 반환받았다.
test를 pass하도록 수정했으니 이제 리팩토링할 부분이 있는지 살펴보자. errors.New 에서 문자열은 Search 에서 1번, Test에서 1번 총 2번쓰인다. 만약 Search에서 해당 문자열을 변경하면 Test가 실패하므로 일종의 magic number가 된다. 따라서 해당 Error를 변수로 추출하도록 하자.
var ErrNotFound = errors.New("could not find the word you were looking for")
func (d Dictionary) Search(word string) (string, error) {
definition, ok := d[word]
if !ok {
return "", ErrNotFound
}
return definition, nil
}
ErrNotFound 라는 error변수로 추출하였으므로 test에서도 error문자열을 assert하지 않고 Error형으로 비교할 수 있다.
t.Run("unknown word", func(t *testing.T) {
_, got := dictionary.Search("unknown")
assertError(t, got, ErrNotFound)
})
func assertError(t testing.TB, got, want error) {
t.Helper()
if got != want {
t.Errorf("got error %q want %q", got, want)
}
}
위와 같이 error의 문구를 변경해도 test가 실패하지 않도록 더 견고한 코드가 되었다.
Search기능을 구현했으니 Add기능을 구현해보자. Add기능이 제대로 동작하는지 확인하기 위해서 이미 구현된 Search 기능을 활용하여 Test코드를 작성한다.
func TestAdd(t *testing.T) {
dictionary := Dictionary{}
dictionary.Add("test", "this is just a test")
want := "this is just a test"
got, err := dictionary.Search("test")
if err != nil {
t.Fatal("should find added word:", err)
}
if got != want {
t.Errorf("got %q want %q", got, want)
}
}
func (d Dictionary) Add(word, definition string) {
}
.........
func (d Dictionary) Add(word, definition string) {
d[word] = definition
}
위와 같이 Compile만 되도록 작성하여 Fail을 확인한 후 Test가 통과하도록 기능을 구현해준다.
- map의 pointer와 복사
map은 함수나 메소드로 넘길 때 포인터형(ex - &myMap)으로 주소를 넘겨주지 않아도 수정할 수 있다. 함수나 메소드로 넘길 때 데이터를 포함한 자료구조가 아닌 포인터를 복사한다.
map의 문제는 nil 값이 될 수 있다는것이다. map에서 읽을때에는 비어있는 것처럼 동작하지만, map에 쓰려고 할 때에는 runtime panic을 발생시킨다. 그래서 일반적으로 사용할 때에는 아래와 같이 비어있는 map을 선언하면 안된다.
var m map[string]string
빈값을 할당하는 방식으로 초기화하거나({}) make를 사용해주는것이 좋다.
var dictionary = map[string]string{}
// OR
var dictionary = make(map[string]string)
map을 이렇게 초기화하는 습관을 들여놓으면 runtime panic에 관해서 걱정할 일은 없다.
func TestAdd(t *testing.T) {
dictionary := Dictionary{}
word := "test"
definition := "this is just a test"
dictionary.Add(word, definition)
assertDefinition(t, dictionary, word, definition)
}
func assertDefinition(t testing.TB, dictionary Dictionary, word, definition string) {
t.Helper()
got, err := dictionary.Search(word)
if err != nil {
t.Fatal("should find added word:", err)
}
if definition != got {
t.Errorf("got %q want %q", got, definition)
}
}
구현상으로는 크게 리팩토링할건 없지만 test코드를 리팩토링해주면 좋을것 같다. assert부분을 helper메소드로 만들고 word와 definition을 변수로 만들어주었다.
다만 한 가지 고려하지 못한 부분이 있는데 이미 같은 word 정의가 존재할 때 값을 덮어쓴다는것이다. 물론 다른 프로그래밍언어에서도 map이라는 자료구조를 사용할 때 이미 존재하는 key에 대한 값을 재할당하면 수정하지만 문제는 우리가 정의한 dictionary의 메소드명이 Add라는것이다. Add라는 메소드명에 적합한 동작은 없는 word에 대해서만 정의해주고 이미 존재하는 정의는 수정하지 않는게 더 나아보인다.
func TestAdd(t *testing.T) {
t.Run("new word", func(t *testing.T) {
dictionary := Dictionary{}
word := "test"
definition := "this is just a test"
err := dictionary.Add(word, definition)
assertError(t, err, nil)
assertDefinition(t, dictionary, word, definition)
})
t.Run("existing word", func(t *testing.T) {
word := "test"
definition := "this is just a test"
dictionary := Dictionary{word: definition}
err := dictionary.Add(word, "new test")
assertError(t, err, ErrWordExists)
assertDefinition(t, dictionary, word, definition)
})
}
...
func assertError(t testing.TB, got, want error) {
t.Helper()
if got != want {
t.Errorf("got %q want %q", got, want)
}
}
위의 코드에서 Add메소드에 대해 이미 존재하는 word를 추가하려고 할 때, ErrWordExists라는 error를 반환하도록 검사하는 test코드를 추가했다. 또 정상적인 Add case(기존에 정의되어 있지 않은 word를 추가할 때)에 대해서 error가 nil인지 검사하는 코드도 추가해주었다.
var (
ErrNotFound = errors.New("could not find the word you were looking for")
ErrWordExists = errors.New("cannot add word because it already exists")
)
func (d Dictionary) Add(word, definition string) error {
d[word] = definition
return nil
}
Test가 정상적으로 실패할것이다. 이제 통과하도록 코드를 구현해주자.
func (d Dictionary) Add(word, definition string) error {
_, err := d.Search(word)
switch err {
case ErrNotFound:
d[word] = definition
case nil:
return ErrWordExists
default:
return err
}
return nil
}
위와 같이 switch를 작성해주면 Search가 반환한 error가 ErrNotFound가 아닐 시 이를 그대로 반환할 수 있다.
- Error wrapping
구현과 관련해서 리팩토링할 사항이 많진 않지만 한 가지 고려해줄만한 점이 있다.
const (
ErrNotFound = DictionaryErr("could not find the word you were looking for")
ErrWordExists = DictionaryErr("cannot add word because it already exists")
)
type DictionaryErr string
func (e DictionaryErr) Error() string {
return string(e)
}
error 상수를 만들고 DictionaryErr형을 새롭게 정의하였다. 또한 DictionaryErr 형은 error 인터페이스를 구현하고 있다.
- map delete
위와 같은 과정을 거치면 Update 기능도 어렵지 않게 구현할 수 있다. delete도 별반 다를점은 없지만 golang에서 map의 항목을 지울때에는 built-in 함수를 사용하므로 따로 다루어보았다.
func TestDelete(t *testing.T) {
word := "test"
dictionary := Dictionary{word: "test definition"}
dictionary.Delete(word)
_, err := dictionary.Search(word)
if err != ErrNotFound {
t.Errorf("Expected %q to be deleted", word)
}
}
위의 test코드에서는 Delete연산을 하면 Search에서 ErrNotFound error를 반환하는지 검사한다.
func (d Dictionary) Delete(word string) {
delete(d, word)
}
map을 delete할 때에는 위와 같이 built-in 함수를 이용한다.
'Language > Go' 카테고리의 다른 글
Go - mocking (0) | 2022.01.02 |
---|---|
Go - dependency injection (0) | 2021.12.31 |
Go - pointers & errors (0) | 2021.12.26 |
Go - struct, method, interface (0) | 2021.12.24 |
Go - array, slice, test coverage (0) | 2021.12.04 |
댓글