공대생 정리노트

Effective Go (2) 요약 본문

언어/Go

Effective Go (2) 요약

woojinger 2021. 11. 1. 12:06

Functions

다중 반환 값

Go에서는 함수나 메서드가 여러 개의 값을 반환할 수 있다.

func (file *File) Write(b []byte) (n int, err error)
func nextInt(b []byte, i int) (int, int) {
    for ; i < len(b) && !isDigit(b[i]); i++ {
    }
    x := 0
    for ; i < len(b) && isDigit(b[i]); i++ {
        x = x*10 + int(b[i]) - '0'
    }
    return x, i
}

Named result parameters

Go 함수에서는 return에 일반 변수처럼 이름을 줄 수 있다.

이름이 지어지면, 함수가 시작될 때 0으로 초기화가 된다.

만약 함수가 인수 없이 return 구문을 사용하면 result parameter의 현재 값이 return 값이 된다

 

이름은 꼭 필요는 없지만 코드를 짧고 깨끗하게 만들 수 있다. 일종의 documentation이다.

func nextInt(b []byte, pos int) (value, nextPos int) {

위 함수의 return 값을 보면 반환 값이 어떤 것인지 명확해진다.

 

Defer

Go의 defer문은 함수가 return하기 직전에 지연된 함수(deferred 함수)를 실행하도록 스케줄링을 한다

함수가 어떤 경로로 return을 하기 전에 release되야 하는(리소스 같은) 것을 처리할 때 효과적인 방법이다.

대표적인 예가 mutex를 unlock하거나 file을 닫는 것이다

// Contents returns the file's contents as a string.
func Contents(filename string) (string, error) {
    f, err := os.Open(filename)
    if err != nil {
        return "", err
    }
    defer f.Close()  // f.Close will run when we're finished.

    var result []byte
    buf := make([]byte, 100)
    for {
        n, err := f.Read(buf[0:])
        result = append(result, buf[0:n]...) // append is discussed later.
        if err != nil {
            if err == io.EOF {
                break
            }
            return "", err  // f will be closed if we return here.
        }
    }
    return string(result), nil // f will be closed if we return here.
}

defer로 close와 같은 함수를 호출하면 두 가지 장점이 있다.

1. file을 닫는 것을 잊지 않게 해준다. 함수를 수정하다 return path를 추가하다 보면 이런 실수를 하기 쉬운데 이를 방지한다

2. close가 open근처에 있어 함수 끝에 있는 것 보다 clear하다

 

defer의 argument는 defer가 실행될 때가 아니라 defer가 call될 때 evaluate된다(defer func 구문이 호출된 시점)

그리고 함수가 실행될 때 변수의 값이 변하는 것을 걱정하지 않아도 된다.

이 말은 하나의 deferred call이 여러 개의 함수 실행을 defer할 수 있다는 것을 의미한다.

for i := 0; i < 5; i++ {
    defer fmt.Printf("%d ", i)
}

이때 deferred 함수는 LIFO 순서로 실행이 되어 위 코드는 4 3 2 1 0으로 출력한다

좀 더 그럴듯한 예는 실행된 함수를 trace하는 것이다

func trace(s string)   { fmt.Println("entering:", s) }
func untrace(s string) { fmt.Println("leaving:", s) }

// Use them like this:
func a() {
    trace("a")
    defer untrace("a")
    // do something....
}

defer함수가 실행이 될 때 evaluate 된다는 사실을 이용하면 다음과 같이 tracing routine을 만들 수 있다

func trace(s string) string {
    fmt.Println("entering:", s)
    return s
}

func un(s string) {
    fmt.Println("leaving:", s)
}

func a() {
    defer un(trace("a"))
    fmt.Println("in a")
}

func b() {
    defer un(trace("b"))
    fmt.Println("in b")
    a()
}

func main() {
    b()
}
entering: b
in b
entering: a
in a
leaving: a
leaving: b

Data

Go에는 두 개의 allocation primitive가 있다. built-in 함수인 new와 make이다

이 두개는 다른 것을 하고 다른 타입을 적용해 햇갈리지만 규칙은 단순하다

 

new

메모리를 할당하는 함수지만 다른 언어에서의 new와는 달리 메모리를 initialize하지 않고, 0으로 만든다.

즉 new(T)는 타입 T에 대한 제로값으로 셋팅된 메모리 공간을 할당하고 *T의 주소값을 할당한다

new가 반환하는 메모리가 제로값으로 셋팅되어 있어 사용자는 자료 구조로 사용할 때 바로 작업을 진행할 수 있다.

ex)

bytes.Buffer -> 바로 사용할 수 있는 제로 값으로 셋팅된 empty buffer로 document에 쓰여짐

sync.Mutes -> init이나 생성자가 따로 없고 제로 값의 sync.Mutex가 unlocked mutex로 정의됨

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

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

SyncedBuffer는 할당이나 선언만 하면 바로 사용할 수 있다.

p와 v 모두 추가 작업 없이 바로 사용 가능하다

 

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

제로 값만으로 충분하지 않을 때 생성자 초기화가 필요하다

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
}

위를 보면 비슷한 코드들이 많이 보인다. 이것을 합성 리터럴을 이용하면 단순화할 수 있다.

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 쌍으로 라벨링하면 순서가 바뀌어도 상관 없고, 빠진 것은 0으로 셋팅한다

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

만약 합성 리터럴에 field가 없다면 제로 값으로 만든다. 즉 new(File)과 &File{}은 같다

 

합성리터럴은 배열, 슬라이스, 맵에도 사용이 가능하다

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"}

// Enone, Eio, Einval의 값만 구분이 된다면 초기화는 진행이 된다

make

make(T, args)는 사용 목적이 new(T)와는 다르다.

슬라이스, 맵, 채널에만 사용될 수 있고 타입 T로 초기화된 값(0이 아닌 값)을 돌려준다.

그 이유는 위 3개의 타입이 사용하기 전에 초기화가 반드시 필요한 자료구조들을 reference하기 때문이다.

슬라이스를 예로 들면, 슬라이스는 배열의 데이터를 가리키는 포인터와 length, capacity를 요소로 가진다.

이 요소들이 초기화 되기 전에는 슬라이스는 nil이다.

make는 슬라이스, 맵, 채널에 대해서 내부 자료구조를 초기화하고 사용할 값들을 준비한다.

make([]int, 10, 100)

위 코드는 100개의 capacity를 가진 배열을 할당하고,

100개의 용량을 가지고 10개의 길이를 가진 배열의 첫 10개의 요소를 가리키는 슬라이스 구조체를 만든다

반대로 new는 제로 값의 슬라이스 구조(nil 값을 가리키는)를 가리키는 포인터를 할당한다

var p *[]int = new([]int)       // allocates slice structure; *p == nil; rarely useful
var v  []int = make([]int, 100) // the slice v now refers to a new array of 100 ints

// Unnecessarily complex:
var p *[]int = new([]int)
*p = make([]int, 100, 100)

// Idiomatic:
v := make([]int, 100)

make -> 맵, 슬라이스, 채널에 대해 적용. 포인터 반환X

new -> 포인터 반환

 

Arrays

Go에서 배열의 동작 방식

- 배열은 값이다. 한 배열을 다른 배열로 assign하면 모든 원소가 복사된다

- 특히, 함수에 배열을 전달하면 그것에 대한 포인터가 아닌 복사본을 받게 된다

- 배열의 크기는 타입의 한 부분이다. 즉 타입 [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에서 사용하는 일반적인 방식이 아니다.

대신 슬라이스를 쓴다

 

Slices

슬라이스는 기반이 되는 배열의 레퍼런스를 가지고 있고 한 슬라이스를 다른 것에 할당하면 같은 배열을 가리키게 된다.

만약 함수에 슬라이스를 인자로 전달하면, 슬라이스에 대한 변화가 caller에게도 보이게 된다. 배열의 포인터를 넘긴 효과와 같은 효과이다. (배열을 넘기게 되면 위에서 보았다 싶이 값을 copy해 넘겨 변화가 보이지 않는다)

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

위 Read 함수는 포인터와 count를 인자로 받기 보다는 슬라이스를 인자로 받는다. 슬라이스가 데이터를 읽을 수 있는 상한을 갖고 있기 때문이다.

슬라이스의 length : 배열의 길이 한도 안에서 변경이 가능

슬라이스의 capacity : built-in 함수인 cap에 의해 접근 가능하다. 

 

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
}

위 코드는 데이터를 슬라이스에 덧붙이는 함수이다. 만약 capacity를 넘게 되면 슬라이스는 재할당된다.

append는 내장함수로 만들어져 있다.

append의 설계를 이해하려면 좀 더 알아야 할 것들이 있다. 아래가 그 내용이다.

Two-dimensional slices

2차원 배열 및 슬라이스를 정의하려면 배열의 배열이나 슬라이스의 슬라이스를 정의해야 한다

type Transform [3][3]float64  // A 3x3 array, really an array of arrays.
type LinesOfText [][]byte     // A slice of byte slices.

슬라이스는 variable-length이기 때문에 inner slice는 각기 다른 길이를 가질 수 있다.

// 한번에 한 라인씩 만드는 2차원 슬라이스
// Allocate the top-level slice.
picture := make([][]uint8, YSize) // One row per unit of y.
// Loop over the rows, allocating the slice for each row.
for i := range picture {
	picture[i] = make([]uint8, XSize)
}

// 한번에 할당하고 라인으로 자르는 2차원 슬라이스
// Allocate the top-level slice, the same as before.
picture := make([][]uint8, YSize) // One row per unit of y.
// Allocate one large slice to hold all the pixels.
pixels := make([]uint8, XSize*YSize) // Has type []uint8 even though picture is [][]uint8.
// Loop over the rows, slicing each row from the front of the remaining pixels slice.
for i := range picture {
	picture[i], pixels = pixels[:XSize], pixels[XSize:]
}

Maps

key와 다른 타입을 연관시켜주는 자료구조.

key는 equality 연산자가 정의되어 있다면 어느 타입이던 될 수가 있다(integer, floating point 등)

포인터, 인터페이스, 구조체 및 배열은 키가 될 수 있으나 슬라이스는 equality가 정의되어 있지 않아 키가 될 수 없다.

슬라이스처럼 Map도 자료구조에 대한 레퍼런스를 가지고 있다. 즉 함수에 map을 넘겨주면 caller에서도 맵의 변화를 볼 수 있다

 

Map은 콤마로 구분되어진 key-value쌍 형태의 composite literal로 생성될 수 있어 초기화하면서 만드는 것이 쉽다

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,
}

Map에 존재하지 않는 키로 값을 가져오면 map의 value 타입의 제로 값을 반환한다

다음은 해당 맵에 key가 있는지 확인하는 코드이다

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

if attended[person] { // will be false if person is not in the map
    fmt.Println(person, "was at the meeting")
}

Map에 값이 없어서 제로 값을 가지는지 있음에도 값이 0인지 구분해야 할 때가 있다. 이는 multiple assignment로 구분한다

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

만약 tz가 있으면 ok는 true값을 가지지만 없는 경우 false 값을 가진다

 

Map의 항목을 삭제하고 싶으면 내장함수인 delete를 사용하면 된다.

delete 함수 인자로 Map과 삭제가 될 key를 주면 된다. 이미 key가 삭제가 되었더라고 안전하다

delete(timeZone, "PDT")

Printing

C처럼 format string을 받는 Printf, Fprintf, Sprintf 외에도 각 argument에 대해 default format을 갖는 Print, Println도 있다.

Fprintf는 io.Writer 인터페이스를 implement하는 object를 첫번째 argument로 받는다.

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

C와는 다르게 %d와 같이 포맷을 넘겨줄 때 size나 signedness를 정해줄 필요가 없다. printing routine이 argument의 타입을 사용해 property를 결정한다. 즉, %llu나 %ld 등을 사용 안하고 %d만 사용해도 된다는 얘기

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

print된 결과

18446744073709551615 ffffffffffffffff; -1 -1

만약 default conversion을 원한다면 %v를 사용하면 된다. %v는 배열, 슬라이스, struct., map등도 출력이 가능하다

fmt.Printf("%v\n", timeZone)  // fmt.Println(timeZone)

// 출력 결과
map[CST:-21600 EST:-18000 MST:-25200 PST:-28800 UTC:0]

struct를 출력할 때는 %+v format으로 출력하면 필드의 이름과 값을 같이 출력할 수 있다. %#v는 full Go syntax로 출력이 가능하다

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에 적용될 수 있게 함. backquote(`)는 가능하면 %#q에서 사용.

%q는 integer나 rune에도 적용이 될 수 있다

%x : integer뿐 아니라 문자열, 배열, 바이트 슬라이스에 사용해 16진수 문자열을 만듬

%T : value의 타입을 출력

만약 custom type의 default format을 control하고 싶다면 해당 타입의 String() string 시그니처를 가진 메소드를 정의하면 된다

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

 print routine들은 재진입이 가능하기에 String 메소드가 Sprintf를 부를 수 있는 것이다.

그러나 String 메소드를 구현할 때 String 메소드를 재귀적으로 호출하면 안된다.

Sprintf 호출에서 receiver를 직접 출력하려고 하면 재귀적 호출이 일어날 수 있다

type MyString string

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

// 고쳐진 예

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

print routine의 인자들을 다른 routine에 직접 넘김으로써 출력을 할 수 있다. 

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

 

Printf에서 v는 []interface{}처럼 행동하지만 다른 함수로 넘어가면 regular list of arguments처럼 행동한다

// 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)
}

위 코드는 log.Println의 구현이다. 

v 뒤에 ...을 쓰면 컴파일러에게 v를 인자의 리스트로 취급하게 해준다. ...을 쓰지 않는다면 v를 하나의 slice argument로 넘기게 된다.

 

 

 

 

'언어 > Go' 카테고리의 다른 글

동시성 성능 테스트 mutex VS chan VS atomic  (0) 2021.12.19
파일 I/O - write과 read시 파일 lock이 있을까?  (0) 2021.12.08
Effective Go (1) 요약  (0) 2021.10.18
동시성 패턴  (0) 2021.05.05
고루틴  (0) 2021.05.03
Comments