※ 디스커버리 Go 언어 라는 책 내용을 전반적으로 다루지만 책 리뷰보다는 좀 더 간단하게 정리해보겠습니다.
5. 구조체
구조체는 필드들을 묵어놓은 것으로 다른 언어들의 구조체, 클래스, 레코드 등과 비슷한 역할을 하며 구조체를 이용하면 더 복잡한 자료형을 정의할 수 있습니다. 인터페이스는 구현은 없고 메서드의 형태만 있는 메서드의 집합입니다. 이번 장에서는 구조체와 인터페이스 이 두가지에 대해 알아보도록 하겠습니다.
5.1.1 구조체 사용법
구조체 자료형으로 된 변수 하나를 선언해보겠습니다.
// 1
var task = struct {
title string
done bool
due *time.Time
}{"laundry", false, nil}
// 2
type Task struct {
title String
done bool
due *time.Time
}
var myTask = Task{"laundry", false, nil}
var myTask2 = Task{title: "laundry"}
var myTask3 = Task{title: "laundry", done: true}
var myTask4 = Task{
title: "laundry",
done: true,
}
1번과 2번 방법 둘은 동일합니다. myTask2 변수처럼 원하는 필드에만 값을 넣을 경우 값이 없는 필드는 기본값으로 설정됩니다.
myTask3 변수와 같이 여러 필드의 값을 정할 땐 쉼표로 구분합니다. 하지만 이렇게 줄이 길어질 경우, myTask4 처럼 사용하는 것이 go 에 가장 맞습니다.
5.1.2 const 와 iota
개발자들은 자주 요구 사항이 변경되고 기능이 확장되는 일을 피하기 어려운데, 확장성을 위해선 bool 형을 쓸 곳에 enum 형을 쓰면 좋습니다. 위에서 선언한 Task 자료형은 done 필드가 bool 형으로 되어있는데 enum 형으로 변경한다면 완료, 미완료 외의 추가적인 상태도 넣을 수 있어서 enum 형으로 변경해보도록 하겠습니다.
type status int
const UNKNOWN = status = 0
const TODO status = 1
const DONE status = 2
type Task struct {
title string
status status
due *time.Time
}
Go 에선 enum 이 따로 없기 때문에 위처럼 상수로 정의해서 사용합니다. 이렇게 정의한 상수를 묶어서 iota 를 사용해보도록 하겠습니다.
// 1
const {
UNKNOWN status = 0
TODO status = 1
DONE status = 2
}
// 2
const {
UNKNOWN status = iota
TODO status = iota
DONE status = iota
}
// 3
const {
UNKNOWN status = iota
TODO
DONE
}
1, 2, 3 모두 동일한 방법이며, 상수에 0, 1, 2 값을 순서대로 붙이지않고 iota 를 위처럼 사용할 수 있습니다. 3번처럼 iota 를 한번만 써줘도 동일하게 적용됩니다.
5.1.3 테이블 기반 테스트
다른 프로그래밍 언어를 이용해 assertion 을 이용한 유닛 테스트를 해왔다면 다른 자료형도 비교해 테스트해주는것과 비교해 제네릭을 지원하지 않는 Go 언어에선 그 부분이 어렵고, 여러 사례를 테스트하고 싶을 때 문제가 더욱 커집니다. 이럴 때 구조체와 배열을 이용해 테이블 기반 테스트를 할 수 있습니다.
func TestFib(t *testing.T) {
cases := []struct {
in, want int
}{
{0, 0},
{5, 5},
{6, 8},
}
for _, c := range cases {
got := seq.Fib(c.in)
if got != c.want {
t.Errorf("Fib(%d) == %d, want %d", c.in, got, c.want)
}
}
}
Go 커뮤니티 관습 역시 if문을 이용하거나 테이블 기반 테스트를 하는 것을 권장하고 있습니다.
5.1.4 구조체 내장
구조체는 여러 자료형의 필드들을 가질 수 있다는 점이 가장 중요합니다. 구조체를 재사용하기 위해 Go 언어에서 지원되는 포함 관계를 활용해 보도록 하겠습니다.
type Deadline time.Time // Deadline 자료형 선언
// Deadline 자료형에 OverDue 메서드 정의
func (d *Deadline) OverDue() bool {
return d != nil && time.Time(*d).Before(time.Now())
}
type Task struct {
Title string
Status status
Deadline *Deadline
}
// Task 의 OverDue 메서드는 Deadline 에 위임
func (t Task) OverDue() bool {
return t.Deadline.OverDue()
}
Task 구조체가 Deadline 자료형의 필드를 가지고 있어서 t.Deadline.OverDue() 처럼 메서드를 이용할 수 있었습니다. 하지만 메서드마다 모두 같은 이름의 메서드를 호출하는 코드를 작성하는 것은 너무 귀찮은 일이며, 이를 덜어주는 것이 내장 기능입니다.
type Task struct {
Title string
Status status
*Deadline
}
이전의 Task 와 같지만 Deadline 에 필드 이름을 생략했고, 이렇게 하면 Task 에 대해 의미없는 OverDue 메서드를 작성할 필요가 없습니다. Task 가 내장하고 있는 *Deadline 자료형은 자료형의 이름과 같은 Deadline 이라는 필드를 갖게 되고 정의되어 있는 메서드도 바로 호출할 수 있게 됩니다.
구조체를 내장하게 되면 내장된 구조체에 들어있는 필드들도 바로 접근이 가능합니다. 따라서 구조체 내장을 이용하면 여러 구조체에 있는 필드들이 모두 합쳐진 구조체 같은 것을 만들 수 있습니다.
5.2 직렬화와 역직렬화
직렬화 (Serialization) 란 객체의 상태를 보관이나 전송 가능한 상태로 변환하는 것입니다. 구조체 만이 아니라 숫자, 문자열, 배열, 맵 역시 직렬화가 가능합니다. 직렬화의 반대로 보관되거나 전송받은 것을 다시 객체로 복원하는 것을 역직렬화 (Deserialization) 라 합니다.
보조기억장치에 저장 및 불러오기 / 네트워크를 통한 메시지 전송 / RPC 등의 방법을 사용할 때 직렬화가 필요합니다.
5.2.1 JSON
- JSON 직렬화 및 역직렬화
t := Task {
"Laundry",
DONE,
NewDeadline(time.Date(2015, time.August, 16, 15, 43, 0, 0, time.UTC)),
}
b, err := json.Marshal(t) // 직렬화
c := []byte`{"Title": "Buy Milk", "Status": 2, "Deadline": "2015-08-16T15:43:00Z"}`)
t2 := Task{}
err := json.Unmarshal(b, &t2) // 역직렬화
JSON 패키지는 대문자로 시작하는 필드들만 JSON 으로 직렬화합니다. 직렬화 하고 싶지 않은 필드는 소문자로 만들면 됩니다. 역직렬화 시에는 포인터를 이용해야 수정된 것이 반영되므로 &t2 를 사용했습니다.
- JSON 태그
기본 직렬화 필드 중 필드 이름을 변경하거나, 표시하지 않고 싶을때 구조체 필드에 json 태그를 붙일 수 있습니다. 이 태그를 JSON 라이브러리가 읽고 처리해줍니다.
type MyStruct struct {
Title string `json:"title""` // "title" 을 필드로 사용
Internal string `json:"-"` // Internal 필드 나타나지 않고 무시
Value int64 `json:",omitempty"` // Value 가 0 인 경우 표시하지 않음
ID int64 `json:",string"` // 64비트 정수형이지만 JSON 에는 문자열로 나타남
}
ID 필드의 경우 그대로 두어도 괜찮지만, 정수값은 53비트를 넘어서면 정확도가 떨어지기 때문에 64비트 정수를 주고받을 때엔 `json:,string` 을 해주는 습관을 기르는 것도 좋습니다.
- JSON 직렬화 사용자 정의
int 타입을 가진 필드의 경우 숫자로 직렬화 되고 역직렬화 되지만 받는 쪽에선 문자열로 받아야 할 경우가 있을 때, 커스텀 코드를 만들어 주면 됩니다.
func (s status) MarshalJSON() ([]byte, error) {
switch s {
case UNKNOWN:
return []byte(`"UNKNOWN"`), nil
case TODO:
return []byte(`"TODO"`), nil
case DONE:
return []byte(`"DONE"`), nil
default:
return nil, errors.New("status.MarshalJSON: unknown value")
}
}
func (s *status) UnmarshalJSON(data []byte) error {
switch string(data) {
case `"UNKNOWN"`:
*s = UNKNOWN
case `"TODO"`:
*s = TODO
case `"DONE"`:
*s = DONE
default:
return errors.New("status.UnmarshalJSON: unknown value")
}
return nil
}
이렇게 추가하면 가능은 하지만 굉장히 반복적인 작업에 상태값이 하나 추가되면 두 군데를 동시에 수정해야 합니다.
이후에 보게 될 go generate* 라는 도구를 이용하면 이런 반복적인 코드를 자동으로 생성할 수 있습니다.
- 구조체가 아닌 자료형 처리
배열을 직렬화 및 역직렬화 하는데 JSON 라이브러리를 쓸 수도 있습니다. 구조체 이외에도 맵을 이용하여 자바스크립트의 오브젝트를 처리할 수 있습니다.
func Example_mapMarshalJSON() {
b, _ := json.Marshal(map[string]string{
"Name": "John",
"Age": 16,
})
fmt.Println(string(b)) // -> {"Age": "16", "Name": "John"}
}
JSON 에 이용하는 맵은 키가 문자열형이어야 합니다. 위의 예제에선 값도 문자열형으로 나타났습니다. 아무 자료형을 담으려면 interface{} 을 사용하면 됩니다.
func Example_mapMarshalJSON() {
b, _ := json.Marshal(map[string]interface{}{
"Name": "John",
"Age": 16,
})
fmt.Println(string(b)) // -> {"Age": 16, "Name": "John"}
}
- JSON 필드 조작하기
생각보다 실무에서 JSON 라이브러리가 잘 적용되지 않는 사례가 있는데, JSON 의 구조에 따라 구조체의 구조가 제한되어 버린다는 점입니다. 구조체 내장을 이용해 이 부분을 해결해 보도록 하겠습니다. 구조체 내장을 이용하면 원래 구조체를 고치지 않고, 원하는 필드들만 제외하거나 추가하여 직렬화할 수 있습니다.
type Fields struct {
VisibleField string
InvisibleField string
}
func ExampleOmitFields() {
f := &Fields{"a", "b"}
b, _ := json.Marshal(struct {
*Fields
InvisibleField string `json:",omitempty"`
Additional string
}{Fields: f, Additional: "c"})
fmt.Println(string(b)) // -> {"VisibleField": "a", "Additional": "c"}
}
빼고 직렬화하고 싶은 필드 InvisibleField 를 함께 넣은 후 `json:",omitempty"` 를 태그로 넣어주고, 이 필드를 초기화하지 않으면 빈 필드가 되어 사라집니다. 그 결과 Additional 은 추가된 형태로 직렬화가 되었습니다.
5.2.2 Gob
Gob 은 언어에서 기본으로 제공하는 또 다른 직렬화 방식입니다. Go 언어에서만 읽고 쓸 수 있는 형태이며, 더 효율적인 변환이 가능하기 때문에 주고받는 코드가 모두 Go 로 되어 있을 때 Gob 을 사용해볼 수 있습니다.
사용시 JSON 과 다른 점은 gob.NewEncoder, gob.NewDecoder 로 인코더와 디코더를 생성해서 각각 io.Writer, io.Reader 를 넘긴다는 점입니다. Gob 패키지문서를 통해 더 자세한 내용을 참고 바라겠습니다.
5.3 인터페이스
인터페이스는 메서드들의 묶음입니다. 인터페이스 이름을 붙일 때는 주로 인터페이스의 메서드 이름에 er 을 붙입니다. io.Reader 는 Read 메서드를 갖고 있는 인터페이스입니다. 문자열로 표현할 수 있는 것들은 fmt.Stringer 가 됩니다.
5.3.1 인터페이스의 정의
인터페이스는 구조체와 매우 유사한 구조를 띠고 있습니다.
interface {
Method1()
Method2(i int) error
}
두 메서드를 정의하고 있는 자료형은 이 인터페이스로 사용할 수 있으며 인터페이스 역시 이름을 붙여줄 수 있습니다.
type Loader interface {
Load(filename string) error
}
구조체의 내장과 비슷한 형식으로 여러 인터페이스를 합칠 수 있습니다.
type ReadWriter {
io.Reader
io.Writer
}
5.3.2 커스텀 프린터
이름 붙인 자료형은 Print 계열의 함수들을 이용해 출력할 때 나타나는 형식을 정의할 수 있습니다. Print 계열 함수들이 문자열이 아닌 자료들을 출력할 때 미리 정해진 방식을 이용하는데 이것을 설정해주고 싶으면 String() 함수를 정의해주면 됩니다.
func (t Task) String() string {
check := "v"
if t.Status != DONE {
check = " "
}
return fmt.Sprint("[%s] %s %s", check, t.Title, t.Deadline)
}
func ExampleTask_String() {
fmt.Println(Task{"Laundry", DONE, nil}) // -> [v] Laundry <nil>
}
5.3.3 정렬과 힙
정렬은 자료들을 어떤 순서에 따라 늘어놓는 것이며, 순환 구조가 생기면 안됩니다. Go 의 sort.Sort 에서 이용하는 방법은 비교 정렬이자 불안정 정렬 (unstable sort) 입니다. 임의의 자료형에 대해 두 자료의 순서를 비교하는 함수는 제네릭을 지원하지 않는 언어에서는 구현하기 까다롭습니다.
- 정렬 인터페이스의 구현
Go 는 제네릭을 지원하지 않지만 인터페이스를 지원하기 때문에 다양한 형태의 정렬을 수행할 수 있습니다. sort 패키지를 보면 sort.Interface 라는 인터페이스를 정의하고 있고 이것에 따르기만 하면 정렬을 할 수 있습니다.
문자열을 대소문자 구분 없이 정렬해 보겠습니다.
type CaseInsensitive []string
func (c CaseInsensitive) Len() int {
return len(c)
}
func (c CaseInsensitive) Less(i, j int) bool {
return strings.ToLower(c[i]) < strings.ToLower(c[j]) ||
(strings.ToLower(c[i]) == strings.ToLower(c[j]) && c[i] < c[j])
}
func (c CaseInsensitive) Swap(i, j int) {
c[i], c[j] = c[j], c[i]
}
func ExampleCaseInsensitive_sort() {
apple := CaseInsensitive([]string{
"iPhone", "iPad", "MacBook", "AppStore",
})
sort.Sort(apple)
fmt.Println(apple) // -> [AppStore iPad iPhone MacBook]
}
- 정렬 알고리즘
일반적으로 싱글 스레드에서 비교 정렬 알고리즘은 빠른 정렬이 아주 좋습니다. 다만 빠른 정렬은 최악의 경우 O(n^2) 가 될 수 있기에 이를 극복하고자 랜덤 피벗을 뽑기도 하고 몇 개의 피벗을 골라서 이용하기도 합니다. 7개 이하의 값들에 대해선 삽입 정렬이 가장 효율적입니다. Go 에서의 sort.Sort 는 기본적으로 빠른 정렬을 이용합니다. 빠른 정렬 최악의 경우를 피하기 위해 피벗 3개를 골라서 가운데 값을 고르는 중위법을 이용합니다. 그렇지만 너무 깊이 빠른 정렬에 빠지면 힙 정렬을 이용하고, 7개 이하의 자료에 대해선 삽입 정렬을 이용합니다.
- 힙
힙은 자료 중에 가장 작은 값을 O(log N) 의 시간 복잡도로 꺼낼 수 있는 자료구조입니다.
heap.Interface 는 sort.Interface 를 내장하고 있습니다. 따라서 5개의 메서드가 구현되어야 합니다. 대소문자 구분없이 꺼낼 수 있는 힙을 구현하려면 정렬 인터페이스에 추가로 두 메서드 구현을 아래와 같이 해야합니다.
func (c *CaseInsensitive) Push(x interface{}) {
*c = append(*c, x.(string))
}
func (c *CaseInsensitive) Pop() interface{} {
len := c.Len()
last := (*c)[len - 1]
*c = (*c)[:len - 1]
return last
}
func ExampleCaseInsensitive_heap() {
apple := CaseInsensitive([]string{
"iPhone", "iPad", "MacBook", "AppStore",
})
heap.Init(&apple)
for apple.Len() > 0 {
fmt.Println(heap.Pop(&apple))
}
// Output:
// Appstore
// iPad
// iPhone
// MacBook
}
5.3.4 외부 의존성 줄이기
func Save(f *os.File) {
...
}
func Save(w io.Writer) {
...
}
첫번째 함수를 테스트 하기 위해선 파일을 넘겨줘야 합니다. 그보단 두번째 함수처럼 io.Writer 를 받으면 실제 구현에서는 파일을 넘기면 되고, 테스트에선 bytes.Buffer 와 같은 것을 넘겨 테스트해볼 수 있습니다.
가능하면 만들어져 있는 인터페이스를 받아서 동작하게 코드를 작성하면 유연하게 테스트할 수 있습니다. 파일시스템에 접근하는 경우 파일시스템 인터페이스를 만들어서 이용하는 것이 유연성을 높이는 데 도움이 됩니다. 이렇듯 인터페이스를 잘 활용하면 외부 의존성을 줄이는 데 많은 도움이 됩니다.
5.3.5 빈 인터페이스와 형 단언
빈 인터페이스는 아무 자료형이나 취급할 수 있으며 꽤 유용히 사용될 수 있습니다. interface{} 타입을 원래의 자료형으로 변환하려면 다른 문법을 사용해야 합니다. 형변환을 할 때 자료형이 맞는지 실행 시간에 검사가 일어나야 하기에, 형 단언 (type assertion) 이라고 합니다.
func ExampleCaseInsensivie_heapString() {
apple := CaseInsensitive([]string{
"iPhone", "iPad", "MacBook", "AppStore",
})
heap.Init(&apple)
for apple.Len() > 0 {
popped := heap.Pop(&apple)
s := popped.(string)
fmt.Println(s)
}
// Output
// AppStore
// iPad
// iPhone
// MacBook
}
heap.Pop 을 보면 interface{} 형을 반환하고 있습니다. 따라서 popped 는 interface{} 형인데, 이것에 .(string) 을 붙여서 필히 문자열형 이라고 형 단언을 한 것입니다. 따라서 for 문안에 있는 s 는 문자열형입니다. 만일 실행시간에 이것이 단언한 형이 아니면 패닉을 발생시키게 됩니다.
형 단언은 빈 인터페이스에서만 쓸 수 있는 것이 아니라, 인터페이스를 실제 자료형으로 받을 때 마찬가지로 형 단언을 사용합니다.
var r io.Reader = NewReader()
f, ok := r.(os.File)
r 이 실제로 os.File 인 경우에 f 를 해당 자료형으로 이용할 수 있습니다. 그러나 자료형이 맞지 않으면 패닉이 발생하기에 위처럼 두번째 값을 써서 검사할 수 있습니다. os.File 인 경우 ok 가 true 가 되고 아닌 경우 ok 가 false 가 되면서 패닉이 발생하지 않습니다.
5.3.6 인터페이스 변환 스위치
인터페이스를 이용해 해당 메서드들을 구현하여 사용하는 것 외에 포괄적으로 인터페이스를 받아서 특정 자료형 혹은 좀 더 좁은 범위의 인터페이스를 구현할 때 경우에 따라서 구현을 달리 하고 싶을 경우 자료형 스위치 (type switch) 를 이용하면 됩니다.
문자열 슬라이스를 받아서 구분자를 사이에 두고 각 문자열을 연결하는 strings.Join 함수를 확장 구현해보도록 하겠습니다.
func Join(sep string, a ...interface{}) string {
if len(a) == 0 {
return ""
}
t := make([]string, len(a))
for i := range a {
switch x := a[i].(type) {
case string:
t[i] = x
case int:
t[i] = strconv.Itoa(x)
case fmt.Stringer:
t[i] = x.String()
}
}
return strings.Join(t, sep)
}
func ExampleJoin() {
t := task.Task{
Title: "Laundry",
Status: task.DONE,
Deadline: nil,
}
fmt.Println(Join(",", 1, "two", 3, t)) // -> 1,two,3,[v] Laundry <nil>
}
switch 문에서 a[i] 를 type 으로 형단언 해 x 에 할당한 모양새입니다. 이렇게 작성하면 case 에서 자료형의 이름을 지정해 각각의 경우 다르게 구현할 수 있습니다. 문자열일때는 그냥 t 에 복사, 정수형일때는 strconv.Itoa 를 호출해 문자열로 바꾸어서 넣어주고 그 외에 fmt.Stringer 인터페이스를 구현하면 String 메서드를 호출해 넣어줍니다.
5.4 Summary
- 필드들의 모음 혹은 묶음을 구조체라고 함. 구조체는 struct { ... } 형태로 각 필드의 이름과 자료형을 나열
- const 를 이용해 정수 상수 정의를 통하여 enum 스타일 구현 가능. iota 를 이용하면 이어지는 숫자 정의 편리
- 구조체를 이용하면 테이블 기반 테스트를 작성하기 편리함
- 구조체 내에 필드 이름 없이 다른 자료형의 이름을 넣으면 해당 자료형이 구조체에 내장되고 구조체에 자료형과 이름이 동일한 필드가 생김
- 직렬화를 통해 자료를 파일에 저장하거나 네트워크로 보낼 수 있고, 역직렬화를 통해 복원 가능
- JSON 형식으로 직렬화할 때 자바스크립트에서 사용되는 것을 염두에 둔다면 64비트 정수형 자료는 `json:",string"` 태그를 붙임
- Gob 형식으로 직렬화 및 역직렬화 가능
- 인터페이스는 메서드들의 묶음으로 이 메서드들이 모두 정의되어 있는 명명된 자료형은 이 인터페이스를 구현하는 것
- 인터페이스를 이용해 새로운 자료형에 대한 정렬 알고리즘과 힙 자료구조 사용 가능
- 인터페이스를 이용해 외부 의존성을 줄이고 유연한 구현 가능
- 형 단언과 인터페이스 자료형 스위치를 이용하면 인터페이스를 구현하는 자료형에 따라 다른 구현 가능
출처 : https://book.naver.com/bookdb/book_detail.nhn?bid=10337667
'Backend > Golang' 카테고리의 다른 글
Go 언어 - [7장 - (1)] (동시성) (0) | 2020.09.05 |
---|---|
Go 언어 - [6장] (웹 어플리케이션 작성) (0) | 2020.08.29 |
Go 언어 - [4장] (함수) (0) | 2020.08.16 |
Go 언어 - [3장] (문자열 및 자료구조) (0) | 2020.08.04 |
Go 언어 - [2장] (Go 환경 설정) (0) | 2020.08.02 |