쉽게 읽는 Effective Go 번역 (3)

[첫번째], [두번째]에 이어 세번째 Effective Go 번역 이어갑니다.



Data

new 를 이용한 할당(Allocation with new)

Go 언어에는 할당을 위한 두 가지 원시 타입이 있는데요.
바로 내장함수(built-in)인 new 와 make 입니다.
그 둘은 서로 역할이 다르고 서로 다른 타입에 적용을 할 수 있어요.
조금 혼동스럽긴 한데 규칙은 간단합니다.

new 에 대해 먼저 얘기해보죠.
new 는 메모리를 할당하는 내장 함수이긴 하지만, 다른 언어에서의 new 와는 좀 다릅니다.
Go 언어의 new 는 메모리를 초기화(initialize)하는 것이 아니라 제로값(zero)으로 설정합니다.
즉, new(T) 라는 것은 T 라는 타입의 새로운 item 을 위해 제로값으로 셋팅된 메모리 공간을 할당하고 그 주소(*T 타입의 값)를 반환합니다.
여기에서 제로값이라는건 T 타입의 제로값을 말합니다.

new 에 의해 제로값으로 셋팅된 메모리가 반환되기 때문에, 여러분의 데이터 구조체를 설계할 때도 별도로 초기화를 할 필요가 없이 사용할 수 있어서 편리합니다.
사용자가 데이터 구조체를 new 로 생성한 후 바로 써먹을 수 있다는 얘기죠.
예를 들어, bytes.Buffer 의 문서를 보면 이런 말이 나와요.
"Buffer 의 제로값이라는 것은 바로 사용할 수 있는 empty 버퍼를 말한다."

이와 유사한 경우로 sync.Mutex 가 있는데요.
sync.Mutex 는 명시적인 생성자(constructor)라든가 초기화(Init) 메소드가 따로 없어요.
대신에 sync.Mutex 의 제로값을 "락이 걸리지 않은(unlocked) 뮤텍스"라고 바로 정의할 수 있습니다.

type SyncedBuffer struct {
    lock    sync.Mutex
    buffer  bytes.Buffer
}

p := new(SyncedBuffer)  // type *SyncedBuffer
var v SyncedBuffer      // type  SyncedBuffer

SyncedBuffer 타입의 값은 할당이나 선언만 하면 바로 사용할 수 있습니다.
위 코드에서 p 와 v 는 둘다 별다른 추가 작업을 하지 않아도 정상적으로 동작합니다.

생성자와 합성 리터럴 (Constructors and composite literals)

때로는 제로값 만으로는 충분하지 않고 생성자를 초기화하는게 필요한 경우도 있어요.
os 패키지에 있는 아래 예처럼요.

func NewFile(fd int, name string) *File {
    if fd < 0 {
        return nil
    }
    f := new(File)
    f.fd = fd
    f.name = name
    f.dirinfo = nil
    f.nepipe = 0
    return f
}

위 코드에는 반복되는 코드가 많은데요.
위 코드를 합성 리터럴(composite literal)를 사용해서 단순하게 표현할 수 있어요.
이를 사용할 때마다 새로운 인스턴스 하나가 생성됩니다.

func NewFile(fd int, name string) *File {
    if fd < 0 {
        return nil
    }
    f := File{fd, name, nil, 0}
    return &f
}

C 와는 다르게 로컬 변수의 주소값을 반환해도 전혀 문제없습니다.
함수가 반환한 후에도 변수의 메모리 공간은 그대로 살아있거든요.
사실, 합성 리터럴의 주소를 사용해도 매번 새로운 인스턴스를 할당하는건 마찬가지입니다.
그래서, 마지막 두 라인은 합쳐서 사용할 수 있어요.

return &File{fd, name, nil, 0}

합성 리터럴의 각 필드들은 순서대로 위치해야하고 하나라도 빠지면 안됩니다.
하지만, 각각의 요소를 명시적으로 field:value 와 같이 라벨을 붙이면, 어떤 위치에 와도 상관이 없고, 누락된 것이 있으면 제로값으로 셋팅됩니다.
그래서, 아래와 같이 작성할 수도 있어요.

 return &File{fd: fd, name: name}

만약 위 코드에서 합성 리터럴에 필드를 하나도 명시하지 않으면 그냥 File 타입의 제로값이 생기는거예요.
즉, new(File)과 &File{} 는 같은 의미입니다.

array 나 slice, map 에 대한 합성 리터럴도 생성될 수 있는데요.
이 때는 index 나 map key 를 이용해서 라벨링을 할 수 있어요.
아래 예에서, Enone, Eio 및 Einval 의 값에 관계없이 서로 구분만 된다면 초기화는 동작합니다.

a := [...]string   {Enone: "no error", Eio: "Eio", Einval: "invalid argument"}
s := []string      {Enone: "no error", Eio: "Eio", Einval: "invalid argument"}
m := map[int]string{Enone: "no error", Eio: "Eio", Einval: "invalid argument"}

make를 이용한 할당(Allocation with make)

할당에 대한 얘기로 돌아가보죠.
내장 함수 make(T, args)는 그 목적이 new(T) 와는 다릅니다.
make 는 슬라이스(slice)나 맵(map), 채널(channel)을 생성할 때만 사용하고, 타입 T (*T 가 아님)의 초기화(initialize, 제로값이 아님)한 값을 돌려줍니다.
이렇게 make 를 new 와 구분하는 이유는 위의 3 가지 타입이 내부적으로는 데이터 구조체에 대한 레퍼런스(reference)이기 때문입니다.
사용하기 전에 초기화가 되어야 하구요.

예를 들면, 슬라이스는 3 개의 요소를 가진 지시자라고 할 수 있는데요.
배열의 데이터를 가리키는 포인터(pointer), 길이(length), 용량(capacity)을 요소로 가집니다.
그 요소들이 초기화(initialize)되기 전에는 슬라이스는 nil 상태입니다.
슬라이스나 맵, 채널을 위해 make 는 내부 데이터 구조를 초기화하고 사용할 준비를 준비를 해줍니다.
예를 들어 아래 코드를 좀 보시죠.

make([]int, 10, 100)

위 코드는 100 개의 int 형 배열을 할당합니다.
그리고, 100 개의 용량과 10 개의 길이, 마지막으로 배열 중에서 앞쪽 10 개의 요소를 가리키는 포인터를 가진 슬라이스 구조체를 만듭니다.
(슬라이스를 만들 때 용량은 생략이 가능합니다. 슬라이스 섹션에서 이를 좀 더 다루겠습니다.)
반면에, new 는 제로값의 슬라이스 구조체를 할당하고 그에 대한 포인터를 리턴합니다.
즉, nil 슬라이스 값에 대한 포인터를 리턴하는거죠.

아래의 예는 make 와 new 의 차이를 보여줍니다.

var p *[]int = new([]int) // 슬라이스 구조체 할당; *p == nil; 잘 사용안함
var v  []int = make([]int, 100) // 슬라이스 v 는 새로운 100 개의 int 를 가진 배열에 대한 레퍼런스

// 아래는 불필요하게 복잡한 사용방법이구요
var p *[]int = new([]int)
*p = make([]int, 100, 100)

// 일반적으로 이렇게 사용합니다
v := make([]int, 100)

make 는 맵, 슬라이스, 채널에 대해서만 사용한다는걸 기억하시구요.
포인터를 리턴하는게 아니라는 것도 기억하세요.
포인터를 얻고 싶으면 new 를 통해 할당을 하거나, 명시적으로 변수의 주소를 사용하세요.

배열(Arrays)

배열은 메모리의 레이아웃을 상세히 설계하는데 유용하고, 때로는 추가적인 할당을 회피할 수 있게 해줍니다.
하지만, 사실 배열은 주로 슬라이스를 구축하는데 이용됩니다.
이 토픽을 논하기 전에 배열에 관해 기본적으로 알고 있어야할 용어들이 있습니다.

Go 언어와 C 에서의 배열의 동작방식에 주요한 차이점들이 있는데요.
Go 언어에서의 동작방식은 다음과 같습니다.

  • 배열은 곧 값입니다. 하나의 배열에서 다른 배열을 할당(assign)하면 배열의 모든 요소들이 복제됩니다.
  • 특히, 함수에 배열을 전달하면 그에 대한 포인터가 아니라 배열의 복사본을 받게 됩니다.
  • 배열의 크기(size)라는건 타입을 이루는 한 부분입니다. 즉, [10]int 타입과 [20]int 타입은 서로 다른 타입인 것입니다.

배열이 곧 값이라는 특성은 유용하긴 하지만 비용이 큽니다.
여러분이 C 와 같은 동작과 효율을 원한다면 배열의 포인터를 전달해야겠죠.

func Sum(a *[3]float64) (sum float64) {
    for _, v := range *a {
        sum += v
    }
    return
}

array := [...]float64{7.0, 8.5, 9.1}
x := Sum(&array)  // Note the explicit address-of operator

하지만, 이런 스타일은 Go 에서 일반적으로 사용하는 방식이 아닙니다.
이렇게 사용하는 대신 슬라이스(slice)를 사용하세요.

슬라이스(slices)

슬라이스는 연속된 일련의 데이터를 좀더 보편적이고, 강력하며, 편리하게 사용할 수 있도록 배열을 감싼 것입니다.
변환 매트릭스같이 명확한 Dimension 을 가져야하는 요소들을 다루는 경우가 아니라면, Go 에서 대부분의 배열 관련 프로그래밍은 슬라이스를 사용할 수 있어요.

슬라이스는 배열을 가리키는 레퍼런스(reference)를 가지고 있어요.
배열은 슬라이스의 기반이 되죠.
만약 하나의 슬라이스를 다른 쪽으로 할당하면, 두개 모두 같은 배열을 보게 됩니다.
만약 함수에 슬라이스를 인자로 전달하면, 함수안에서 변경하는 슬라이스 요소들이 함수를 호출한 쪽에도 보이게(visible) 됩니다.
이는 배열에 대한 포인터를 넘기는 것과 같은 효과인거죠.
아래 코드처럼 Read 함수는 포인터와 count 를 인자로 받기 보다는 슬라이스를 받을 수 있습니다.
슬라이스의 length 값은 데이터를 어디까지 읽을 수 있는지 상한(upper limit)을 가지고 있거든요.

아래 코드는 os 패키지에 있는 File 타입의 Read 메소드의 Signature 를 나타냅니다.

func (f *File) Read(buf []byte) (n int, err error)

이 메소드는 읽은 바이트 수(읽은 데이터가 있다면)와 error 값을 반환하는데요.
32 바이트보다는 큰 buffer 에서 초기 32 바이트만 읽고 싶다면 아래처럼 buffer 를 잘라서(slicing) 읽으면 됩니다.

n, err := f.Read(buf[0:32])

이렇게 잘라서 읽는 것은 일반적으로 많이 사용되는 방법이면서 효율적이기도 합니다.
사실 효율과 좀 거리는 있지만 아래 코드 조각도 buffer 의 첫 32 바이트를 읽기 위한 코드이긴 합니다.

    var n int
    var err error
    for i := 0; i < 32; i++ {
        nbytes, e := f.Read(buf[i:i+1])  // Read one byte.
        n += nbytes
        if nbytes == 0 || e != nil {
            err = e
            break
        }
    }

슬라이스의 길이(length)는 그 바탕의 배열의 크기 한도 내에서는 변경될 수 있습니다.
그냥 슬라이스 자체에 할당하면 됩니다.
슬라이스의 용량(capacity)은 내장 함수인 cap 을 통해 접근할 수 있는데, 이는 슬라이스의 크기(length)가 늘어날 수 있는 최대 크기를 의미합니다.

아래 코드는 데이터를 슬라이스에 추가(append)하는 함수입니다.
만약 데이터가 용량(capacity)를 넘어서면 슬라이스는 재할당되고 재할당된 슬라이스가 반환됩니다.
이 함수는 len 과 cap 가 nil 슬라이스에서도 합법적으로 사용될 수 있고 0 을 반환한다는 사실을 이용하고 있습니다.

func Append(slice, data []byte) []byte {
    l := len(slice)
    if l + len(data) > cap(slice) {  // reallocate
        // Allocate double what's needed, for future growth.
        newSlice := make([]byte, (l+len(data))*2)
        // The copy function is predeclared and works for any slice type.
        copy(newSlice, slice)
        slice = newSlice
    }
    slice = slice[0:l+len(data)]
    copy(slice[l:], data)
    return slice
}

위 함수를 보면 슬라이스를 반환해야 하는데요.
이는 슬라이스 자체(포인터, 길이, 용량을 가지고 있는 데이터 구조체)가 값으로 전달되기 때문입니다.

슬라이스에 appending 을 한다는 아이디어는 너무 유용해서 내장함수 append 로 만들어져 있어요.
그런데, 그 함수의 설계사상을 이해하려면 우리가 좀 더 알아야할 것들이 있어요.
그래서, 그런 부분들을 좀 더 살펴보고 다시 돌아오도록 하죠.

이차원 슬라이스(Two-dimensional slices)

Go 언어의 배열과 슬라이스는 일차원이예요.
2 차원 배열이나 슬라이스를 생성하려면 배열의 배열이나 슬라이스의 슬라이스를 다음과 같이 정의해야 합니다.

type Transform [3][3]float64  // 3x3 배열, 배열의 배열
type LinesOfText [][]byte     // byte 슬라이스의 슬라이스

슬라이스는 길이(length)가 가변적이므로, 각 슬라이스의 내부 슬라이스는 서로 다른 길이를 가질 수 있어요.
아래 예는 아주 일반적인데요.
각각의 라인은 서로 독립적으로 다른 길이를 가지고 있습니다.

text := LinesOfText{
	[]byte("Now is the time"),
	[]byte("for all good gophers"),
	[]byte("to bring some fun to the party."),
}

때때로 2 차원 슬라이스의 할당이 필요한 경우가 있어요.
예를 들면, 여러 줄의 픽셀을 읽어들여야하는 경우처럼 말이죠.

이런 일을 처리하는 두 가지 방법이 있습니다.
첫번째 방법은 각각의 독립적인 슬라이스를 할당하는 것입니다.
또 다른 방법은 하나의 배열을 할당하고 각 요소는 독립적인 슬라이스를 가리키도록 하는 방법입니다.
어떤 방법을 사용할지는 여러분의 응용 프로그램에 따라 다릅니다.
만약 슬라이스가 늘거나 줄어들어야하는 경우라면, 다음 라인을 덮어쓰지 않도록 서로 독립적으로 할당되어야 합니다.
만약 슬라이스가 고정된 크기로 이용된다면 한번의 할당으로 객체를 좀 더 효율적으로 구축할 수 있겠죠.

참고로 위 두 가지 방법에 대해 코드로 간략히 만들어 봤습니다.

첫번째로 한번에 한 라인씩 처리하는 경우예요.

// 최상위 슬라이스 할당
picture := make([][]uint8, YSize) // One row per unit of y.

// 라인마다 loop 처리, 각각의 라인에 대해 슬라이스 할당
for i := range picture {
	picture[i] = make([]uint8, XSize)
}

아래는 한번에 할당하고 라인으로 잘라내는 경우예요.

// 최상위 슬라이스 할당. 위와 동일.
picture := make([][]uint8, YSize) // One row per unit of y.

// 모든 픽셀을 저장하기 위한 큰 슬라이스 할당
pixels := make([]uint8, XSize*YSize) // 그림은 [][]int8 타입이지만 []uint8 로 할당

// 각 라인에 대해 loop 처리.
// 전체 슬라이스의 남은 부분의 앞쪽에서 각 라인에 해당되는 슬라이스를 잘라냄.
for i := range picture {
	picture[i], pixels = pixels[:XSize], pixels[XSize:]
}

Maps

맵(Map)은 하나의 타입(key)을 또 다른 타입(element 또는 value)과 연관지어주는 편리하고 강력한 내장 데이터 구조체입니다.
key 는 equality 연산자가 정의되어 있다면 어떤 타입이든 사용될 수 있어요.
integer, float, 합성 수(complex numbers), 문자열(string), 포인터(pointer), 인터페이스(equality 연산자를 제공하는 타입일 경우), 구조체와 배열 등이 모두 사용될 수 있죠.
슬라이스의 경우는 equality 가 정의될 수 없는 타입이기 때문에 맵의 key 로 사용될 수 없습니다.

슬라이스와 마찬가지로 맵의 경우도 그 바탕이 되는 데이터 구조체의 레퍼런스(reference)를 가지고 있습니다.
맵을 함수의 인자로 넘겨주게 되면 함수 안에서 역시 맵의 내용을 변경할 수가 있는데요.
이러한 변경은 호출하는 측에도 보이게(visible) 됩니다.

맵은 콜론과 함께 key-value 쌍의 형태로 일반적인 합성 리터럴을 이용하여 구축할 수가 있는데요.
그래서, 초기화하면서 구축하는게 쉽습니다

var timeZone = map[string]int{
    "UTC":  0*60*60,
    "EST": -5*60*60,
    "CST": -6*60*60,
    "MST": -7*60*60,
    "PST": -8*60*60,
}

맵의 값을 할당하거나(assign) 가져오는(fetch) 방법은 배열이나 슬라이스와 구문이 유사합니다.
index 가 integer 일 필요는 없다는 점을 제외하면요.

offset := timeZone["EST"]

만약 맵에 없는 key 로 값을 가져오게 되면 value 타입의 제로값을 반환하게 됩니다.
예를 들면, 맵이 integer 를 담고 있을 때 만약 존재하지 않는 key 로 검색을 하게 되면 0 을 반환합니다.
value 타입이 bool 인 맵을 활용하면 set 을 구현할 수도 있습니다.
set 에 value 를 넣을 때 맵의 항목(entry)으로 true 를 셋팅한 후 간단한 인덱싱(indexing)으로 이를 시험해볼 수 있습니다.

attended := map[string]bool{
    "Ann": true,
    "Joe": true,
    ...
}

if attended[person] { // person 이 맵에 없으면 false 가 리턴되겠죠
    fmt.Println(person, "was at the meeting")
}

때로는 항목이 없는 경우와 제로값을 구분해야할 경우가 있는데요.
"UTC" 에 대한 항목이 맵에 있는데 값이 0 인 것인지, 또는 맵에 항목이 없어서 값이 0 인 것인지를 구분해야 한다는거죠.
복수 할당의 형식을 사용해서 이를 구분할 수 있습니다.

var seconds int
var ok bool
seconds, ok = timeZone[tz]

이것을 "comma ok" 관용구라고 부르는데요.
이 예에서는, 만약 tz key 에 대한 값이 존재한다면 ok 는 true 로 반환될겁니다.
만약 존재하지 않는다면 false 로 반환될 것이구요.
아래 함수는 이를 보기 좋게 error report 와 함께 사용한 경우입니다.

func offset(tz string) int {
    if seconds, ok := timeZone[tz]; ok {
        return seconds
    }
    log.Println("unknown time zone:", tz)
    return 0
}

실제 들어있는 값에 상관없이 맵에 값이 존재하는지를 시험하고 싶으면, 공백 지시자(underbar '_')를 value 가 반환되는 자리에 사용하면 됩니다.

_, present := timeZone[tz]

맵의 항목을 삭제하려면 내장 함수 delete 를 사용하면 됩니다.
delete 함수의 인자로 삭제될 맵과 key 를 주면 됩니다.
맵에 이미 해당 항목이 없을 때 delete 를 사용해도 안전합니다.

delete(timeZone, "PDT")

출력(Printing)

Go 언어에서 format 출력을 사용하는 스타일은 C 언어의 printf 와 유사하긴 한데, 훨씬 더 기능이 풍부하고 보편적입니다.
fmt 패키지에 함수들이 구현되어 있고 첫글짜가 대문자로 되어 있습니다.
(fmt.Printf, fmt.Fprintf, fmt.Sprintf 등)
Sprintf 등의 문자열 함수들은 제공된 buffer 를 채우기보다는 문자열을 반환합니다.
(C 와 다른 점)

반드시 format 문자열을 제공할 필요는 없습니다.
Printf, Fprintf 와 Sprintf 는 각각 쌍을 이루는 또 다른 함수가 있어요.
예를 들면, Print 나 Println 같은 함수 말이죠.
이런 함수들은 format 문자열을 받는 대신 각각의 인자들에 대해 기본 format 을 만들어냅니다.
Println 의 경우 각 인자들 사이에 공백(blank)을 추가하고 출력 결과에 newline 을 추가해줍니다.
반면에 Print 함수는 각 피연산자들이 양쪽 모두 문자열이 아닌 경우에만 공백을 추가해 줍니다.

아래의 예는 각각의 라인이 똑같은 출력 결과물을 만들어 냅니다.

fmt.Printf("Hello %d\n", 23)
fmt.Fprint(os.Stdout, "Hello ", 23, "\n")
fmt.Println("Hello", 23)
fmt.Println(fmt.Sprint("Hello ", 23))

fmt.Fprint 함수와 그 동료 함수들은 io.Writer 인터페이스를 구현한 객체를 첫번째 인자로 받아들입니다.
os.Stdout 과 os.Stderr 가 친숙한 예가 되겠네요.

이번에는 C 언어보다 좀 더 확장된 부분인데요.
첫번째로, C 와는 달리 %d 와 같은 숫자 포맷은 부호 관련 flag 나 크기를 따로 줄 필요가 없습니다.
대신 출력 함수 내에서 (알아서) 인자의 타입을 이용해서 이러한 특성들을 파악합니다.
(역자: C 의 printf 함수에서처럼 %llu %ld 등과 같이 부호나 크기를 알려주기 위한 포맷팅 방법을 사용할 필요가 없다는 얘기. %d 하나로 모두 해결)

var x uint64 = 1<<64 - 1
fmt.Printf("%d %x; %d %x\n", x, x, int64(x), int64(x))

위의 코드 출력 결과는 다음과 같습니다.

18446744073709551615 ffffffffffffffff; -1 -1

정수형(integer)에 대한 십진수처럼 기본적인 변환을 원할 때는 그냥 다목적으로 사용되는 %v("value" 의 v)를 사용하면 됩니다.
이는 Print 와 Println 이 만들어내는 결과와 완전히 동일하죠.
게다가 %v 는 배열이나 슬라이스, 구조체, 맵등 어떤 값들도 출력을 할 수 있습니다.

아래 코드는 이전 섹션에서 사용했던 타임존 맵을 출력해본 것입니다.

fmt.Printf("%v\n", timeZone)  // 또는 그냥 fmt.Println(timeZone)

위 코드는 아래와 같이 출력됩니다.

map[CST:-21600 EST:-18000 MST:-25200 PST:-28800 UTC:0]

맵의 경우 Printf 와 그 동료함수들은 출력 결과를 key 값을 기준으로 사전순으로 정렬해 줍니다.

구조체를 출력할 때는 %+v 포맷을 사용할 수 있는데요.
이렇게 하면 구조체 필드를 출력할 때 필드의 이름을 주석으로 함께 출력해 줍니다.
그리고, %#v 를 사용하면 어떤 값이든 Go 의 문법으로 출력해 줍니다.

type T struct {
    a int
    b float64
    c string
}
t := &T{ 7, -2.35, "abc\tdef" }
fmt.Printf("%v\n", t)
fmt.Printf("%+v\n", t)
fmt.Printf("%#v\n", t)
fmt.Printf("%#v\n", timeZone)

위의 예제는 다음과 같이 출력해 줍니다.

&{7 -2.35 abc   def}
&{a:7 b:-2.35 c:abc     def}
&main.T{a:7, b:-2.35, c:"abc\tdef"}
map[string]int{"CST":-21600, "EST":-18000, "MST":-25200, "PST":-28800, "UTC":0}

(엠퍼센드(&)에 주목하세요)
인용 문자열 포맷은 %q 를 통해 string 및 []byte 의 값에 적용되어 사용할 수도 있어요.
%#q 와 같이 사용할 수도 있는데 이 때는 인용부호 대신에 가능하면 backquote(`)를 사용합니다.
(%q 포맷은 integer 나 rune 에도 적용되어 단일 인용부호 포맷의 rune 상수를 만듭니다.)
%x 는 integer 뿐 아니라 문자열(string)이나 배열, 바이트 슬라이스(byte slice)에 사용할 수 있는데, 이 때 긴 16 진수의 문자열을 만들어냅니다.
만약 (% x)와 같이 사용하면 각각의 byte 사이에 공백(space)을 넣어주고요.

또 다른 유용한 포맷은 %T 인데, 이는 value 의 타입을 출력해줍니다.

fmt.Printf("%T\n", timeZone)

이는 아래와 같이 출력해 줍니다.

map[string]int

만약 사용자 타입에 대해 기본(default) 포맷을 제어하고 싶으면, 그 타입에 대해 String() string 형태의 Signature 를 가진 메소드를 정의해 주기만 하면 됩니다.
단순한 T 타입에 대해 아래처럼 하시면 됩니다.

func (t *T) String() string {
    return fmt.Sprintf("%d/%g/%q", t.a, t.b, t.c)
}
fmt.Printf("%v\n", t)

이는 아래와 같은 포맷으로 출력해 줍니다.

7/-2.35/"abc\tdef"

(만약 T 에 대한 포인터 뿐 아니라 T 타입의 값을 출력하고 싶으면 위의 메소드에서 receiver 를 value 타입으로 하면 됩니다. 위의 예에서는 구조체에 대해 포인터를 사용했는데, 이렇게 하는 것이 더 효율적이고 관용적이라서 그렇습니다. 좀 더 자세한 내용은 "pointers vs. value receivers" 부분을 참고하세요.)

위의 String 메소드에서 Sprintf 를 호출할 수 있는 이유는 print routine 은 재진입(reentrant)이 충분히 가능하고 이런 방식으로 감쌀 수가 있기 때문입니다.
하지만, 이런 방식으로 접근할 때 알아야할 중요한 점이 한가지 있는데요.
Sprintf 를 호출해서 String 메소드를 구현할 때 절대 여러분의 String 메소드를 재귀적으로 계속 호출하는 방식으로는 구현하면 안된다는 점입니다.
만약 Sprintf 호출에서 receiver 를 직접 출력하려고 시도하면 이런 일이 일어날 수 있습니다.
그러면 계속해서 다시 메소드 호출이 발생할 수 있거든요.
굉장히 흔하게 저지르기 쉬운 실수입니다.
아래 예처럼요.

type MyString string

func (m MyString) String() string {
    return fmt.Sprintf("MyString=%s", m) // Error: will recur forever.
}

고치기도 쉬워요.
Sprintf 의 인자를 메소드가 없는 기본 string 타입으로 변경하면 됩니다.

type MyString string
func (m MyString) String() string {
    return fmt.Sprintf("MyString=%s", string(m)) // OK: note conversion.
}

초기화(Initialization) 섹션(아직 번역전)에서 이러한 재귀 상황을 피하는 또 다른 방법을 알아볼 거예요.

출력을 하는 또 다른 방법은 출력 routine 의 인자들을 또 다른 출력 routine 으로 직접 넘겨주는거예요.
아래 Printf 의 signature 를 보면, 다양한 갯수의 다양한 타입의 인자를 format 다음에 전달하기 위해 ...interface{} 타입을 사용하고 있는데요.

func Printf(format string, v ...interface{}) (n int, err error) {

Printf 함수 내에서 v 는 마치 []interface{} 타입의 변수처럼 동작하죠.
하지만, 이것이 또 다른 함수로 전달이 되면 이는 인자 리스트(list of arguments)로 동작합니다.
아래 코드가 우리가 위에서 사용했던 log.Println 함수의 구현입니다.
함수 인자들을 모두 실제 포맷팅을 위해 곧 바로 fmt.Sprintf 로 넘기고 있네요.

// Println prints to the standard logger in the manner of fmt.Println.
func Println(v ...interface{}) {
    std.Output(2, fmt.Sprintln(v...))  // Output takes parameters (int, string)
}

컴파일러에게 v 를 인자 리스트로 취급하도록 알려주기 위해 v 뒤에 ... 를 사용합니다.
이렇게 하지 않으면 v 를 단지 하나의 슬라이스 인자인 것처럼 넘겨주게 될거예요.

출력 관련해서는 우리가 여기에서 다루었던 것보다 훨씬 많은 내용들이 있어요.
좀 더 자세히 알고 싶으면 fmt 패키지의 godoc 문서를 보시면 됩니다.

그런데, ... 인자는 특별한 타입이 될 수도 있는데요.
예를 들어, min 함수에서 ...int 인자를 사용하면 이는 integer 리스트들 중에서 가장 작은 것을 선택합니다.

func Min(a ...int) int {
    min := int(^uint(0) >> 1)  // largest int
    for _, i := range a {
        if i < min {
            min = i
        }
    }
    return min
}

Append

append 내장함수의 설계를 설명하기 위해 필요하면서도 아직 언급이 안된 부분이 있는데요.
append 의 signature 는 우리가 위에서 만들었던 Append 함수와 다르게 생겼습니다.
대략 아래처럼 생겼는데요.

func append(slice []T, elements ...T) []T

T 는 주어진 타입에 대한 placeholder 인데요.
Go 언어에서는 호출하는 측에서 결정하는 T 타입을 사용하는 함수를 작성할 수가 없습니다.
그래서 append 가 내장 함수로 만들어진거예요.
컴파일러가 지원해줘야 하거든요.

append 가 하는 일은 슬라이스의 끝에 요소를 추가하고 그 결과를 반환하는 일입니다.
그 결과가 반환이 되어야 이유가 있는데요.
위에 우리가 직접 만든 Append 함수에서 본 것처럼 내부에서 사용하는 배열이 변할 수 있기 때문입니다.
아래의 간단한 예는 [1 2 3 4 5 6]을 출력합니다.

x := []int{1,2,3}
x = append(x, 4, 5, 6)
fmt.Println(x)

이렇게 append 는 약간 Printf 와 유사하게 동작합니다.
무작위 갯수의 인자들을 사용하잖아요.

만약 우리가 만든 Append 처럼 동작하길 원하고 슬라이스에 슬라이스를 추가하려면 어떻게 해야할까요?
쉽습니다.
호출 지점에서 ... 를 사용하면 됩니다.
우리가 위에서 Output 을 호출할 때 했던 것 처럼요.
아래의 코드 조각은 위에서 본 것과 같은 결과를 출력합니다.

x := []int{1,2,3}
y := []int{4,5,6}
x = append(x, y...)
fmt.Println(x)

... 없이는 타입이 틀려서 컴파일이 안될거예요.
y 는 int 타입이 아니게 되잖아요.


"쉽게 읽는 Effective Go 번역 (4)"에서 만나요~

You may also like...

0 0 votes
Article Rating
Subscribe
Notify of
guest
0 Comments
Inline Feedbacks
View all comments
0
Would love your thoughts, please comment.x
()
x