ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Go 언어는 매력적이다
    Go 2024. 6. 1. 17:32

    익명 필드를 사용한 상속의 문제점

    Go 언어는 객체 지향 언어는 아니지만, 구조체와 리시버를 사용하여 객체의 멤버를 "호출"할 수 있습니다.

     

    먼저, Animal "클래스"를 만들어보고, 자기소개를 위한 리시버 Describe()도 정의해보겠습니다.

    type Animal struct {
            Age int
    }
    
    func (animal *Animal) Describe() {
            fmt.Printf("I am %v years old.\n", animal.Age)
    }

     

    이번에는 Person "클래스"를 추가해봅시다.

     

    사람은 동물이므로 Person "클래스"에서는 Animal "클래스"를 상속하고 싶습니다.

     

    Go 언어에서는 익명 필드를 사용하여 Person 구조체에 Animal 구조체의 기능을 포함시킬 수 있습니다.

    type Person struct {
            Animal
            Name string
    }
    
    func (person *Person) Describe() {
            fmt.Printf("I am %v, %v years old.\n", person.Name, person.Age)
    }

     

    이제 Plant(식물)도 정의해봅시다.

     

    식물은 자기소개를 할 수 없으므로 Describe 메서드는 필요 없습니다.

    type Plant struct {}

     

    이제 Animal과 Person, Plant 객체를 각각 생성하고 자기소개를 해보도록 하겠습니다.

    func callDescribe(obj interface{}) {
            switch obj.(type) {
            case *Animal:
                    (obj.(*Animal)).Describe()
            default:
                    fmt.Println("It is not an animal.")
            }
    }
    
    func main() {
            var animal interface{} = &Animal{10}
            var person interface{} = &Person{Animal{20}, "Joe"}
            var plant  interface{} = &Plant{}
            callDescribe(animal)
            callDescribe(person)
            callDescribe(plant)
    }

     

    실행 결과는 다음과 같습니다.

    I am 10 years old.
    It is not an animal.
    It is not an animal.

     

    동물이어야 할 사람이 "동물이 아니다"라고 판별되었습니다.

    해결 방법

    대상이 동물인지 판별할 때 인터페이스를 사용하면 잘 해결됩니다.

    type LooksLikeAnimal interface {
            Describe()
    }
    
    func callDescribe(obj interface{}) {
            switch obj.(type) {
            case LooksLikeAnimal:
                    (obj.(LooksLikeAnimal)).Describe()
            default:
                    fmt.Println("It is not an animal.")
            }
    }

     

    실행 결과:

    I am 10 years old.
    I am Joe, 20 years old.
    It is not an animal.

     

    기대한 대로 출력되었습니다.

    리시버를 사용할 때 주의할 점

    다음 코드를 봐주세요.

    type Animal struct {}
    
    func (animal *Animal) DescribeP() {
            fmt.Println("I am a pointer of animal.")
    }
    
    func (animal Animal) Describe() {
            fmt.Println("I am animal.")
    }
    
    func main() {
            var animal Animal = Animal{}
            var panimal *Animal = &Animal{}
            animal.Describe()
            animal.DescribeP()
            panimal.Describe()
            panimal.DescribeP()
    }

     

    실행 결과는 다음과 같습니다.

    I am animal.
    I am a pointer of animal.
    I am animal.
    I am a pointer of animal.

     

    Go 언어의 리시버는 포인터 타입이든 값 타입이든 동일하게 작동합니다(값 전달과 참조 전달의 차이는 있지만).

     

    다음으로 main 함수를 다음과 같이 변경해보겠습니다.

    func main() {
            var panimal *Animal = nil
            panimal.DescribeP()
            panimal.Describe()
    }

     

    출력은 다음과 같습니다.

    I am a pointer of animal.
    panic: runtime error: invalid memory address or nil pointer dereference

     

    첫 번째 포인터 리시버 호출은 성공했지만, 두 번째 호출은 런타임 오류로 실패했습니다.

     

    이는 포인터가 아닌 리시버를 사용할 때 주의해야 한다는 점을 보여줍니다.

     

    또 다른 예제를 봅시다.

    func main() {
            var animal Animal
            var i interface{} = animal
            animal.DescribeP()       // ok
            i.(Animal).Describe()    // ok
            i.(Animal).DescribeP()   // err: cannot take the address of i
    }

     

    이번에는 컴파일 오류가 발생했습니다.

     

    이는 기술적 제약이며, 포인터 리시버를 사용할 때 주의해야 한다는 점을 보여줍니다.

    배열 길이 생략 표현

    Go 언어에서 배열 선언과 복사는 다음과 같이 할 수 있습니다.

    func main() {
            primes := [6]int{2, 3, 5, 7, 11, 13}
            var arr [6]int = primes
            fmt.Println(arr)
    }

     

    그러나 처음 primes 선언은 다소 장황합니다.

     

    예를 들어, C 언어에서는 배열 선언과 초기화를 동시에 할 때 요소 수를 생략할 수 있습니다.

    int primes[] = {2, 3, 5, 7, 11, 13};

     

    Go 언어에서 같은 작업을 하면 어떻게 될까요?

    func main() {
            primes := []int{2, 3, 5, 7, 11, 13}
            var arr [6]int = primes
            fmt.Println(arr)
    }

     

    결과는 다음과 같이 에러가 발생합니다.

    cannot use primes (type []int) as type [6]int in assignment

    해결 방법

    Go 언어에서 배열 선언 시 요소 수를 생략하려면 ...를 사용합니다.

    func main() {
            primes := [...]int{2, 3, 5, 7, 11, 13}
            var arr [6]int = primes
            fmt.Println(arr)
    }

    슬라이스

    앞서 본 primes := []int{2, 3, 5, 7, 11, 13}는 배열이 아니라 슬라이스 초기화를 의미합니다.

     

    다음 예와 같이, Go 언어에서는 배열보다 슬라이스를 활용하는 것이 더 편리합니다.

    // 길이가 0인 슬라이스
    var a = make([]int, 0)
    a = append(a, 12)
    
    // 처음부터 초기화된 슬라이스
    var b = []int{1, 1, 2, 3, 5}
    fmt.Println(a, b)  // 출력: [12] [1 1 2 3 5]

    에러 핸들링

    Go 언어에는 try...catch와 같은 에러 처리 기구가 없습니다.

     

    정확히 말하면 panic이 있지만, 일반적으로 사용을 권장하지 않습니다(회복할 수 없는 경우에만 사용).

     

    참고: https://dave.cheney.net/2012/01/18/why-go-gets-exceptions-right

    In panicing you never assume that your caller can solve the problem. Hence panic is only used in exceptional circumstances, ones where it is not possible for your code, or anyone integrating your code to continue.

     

    따라서 에러는 기본적으로 모두 반환 값으로 처리합니다. 예:

    package main
    
    import (
            "fmt"
            "os"
    )
    
    func main() {
            file, err := os.Create("test.txt")
            if err != nil {
                    fmt.Fprintf(os.Stderr, "File create error\n")
            }
            defer file.Close()
    }

     

    이제 디렉토리를 만들고 싶다면 main 함수를 어떻게 변경해야 할까요?

    func main() {
            file, err := os.Create("test.txt")
            if err != nil {
                    fmt.Fprintf(os.Stderr, "File create error\n")
            }
            err := os.Mkdir("foo", 0777)
            if err != nil {
                    fmt.Fprintf(os.Stderr, "File create error\n")
            }
            defer file.Close()
    }

     

    한눈에 보기에는 맞는 것 같지만, 컴파일 오류가 발생합니다.

    no new variables on left side of :=

     

    Go 언어에서 :=는 변수를 선언하고 동시에 할당하는 연산자입니다.

     

    그러나 변수를 중복 선언할 수 없으므로, 두 번째 err := ... 부분에서 오류가 발생합니다.

     

    따라서 두 번째 :==로 변경해야 합니다.

    func main() {
            file, err := os.Create("test.txt")
            if err != nil {
                    fmt.Fprintf(os.Stderr, "File create error\n")
            }
            err = os.Mkdir("foo", 0777)
            if err != nil {
                    fmt.Fprintf(os.Stderr, "File create error\n")
            }
            defer file.Close()
    }

     

    하지만 다른 해결책도 있습니다.

     

    실제로 이 예제에서는 파일과 디렉토리 생성 순서를 바꾸면 :=를 그대로 사용할 수 있습니다.

    func main() {
            err := os.Mkdir("foo", 0777)
            if err != nil {
                    fmt.Fprintf(os.Stderr, "Directory create error\n")
            }
            file, err := os.Create("foo/test.txt")
            if err != nil {
                    fmt.Fprintf(os.Stderr, "File create error\n")
            }
            defer file.Close()
    }

     

    Go 언어의 사양상, 여러 변수를 :=로 할당할 때 두 번째 이후의 변수는 이미 존재하더라도 에러가 발생하지 않습니다(타입이 맞지 않으면 에러가 발생합니다).

     

    위 예제에서는 에러 처리를 두 번 하고 있는데, 조금 번거롭습니다.

     

    이를 한 번에 처리할 방법은 없을까요?

    func main() {
            err := os.Mkdir("foo", 0777)
            if err == nil {
                    file, err := os.Create("foo/test.txt")
                    if err == nil {
                            file.WriteString("abc")
                            defer file.Close()
                    }
            }
            if err == nil {
                    fmt.Println("Success!")
            } else {
                    fmt.Fprintf(os.Stderr, "%v\n", err)
            }
    }

     

    에러 처리를 마지막에 모아두었습니다.

     

    추가로 파일 쓰기 작업도 추가했습니다.

     

    이는 겉보기에 잘 작동하는 것처럼 보입니다.

     

    이제 의도적으로 에러를 발생시키기 위해 4번째 줄을 file, err := os.Create("bar/test.txt")로 변경해봅시다.

     

    쓰기 대상 디렉토리가 존재하지 않으므로 에러가 발생할 것입니다.

    $ go run test.go
    Success!
    $ ls foo
    .  ..
    $ ls bar
    ls: cannot access 'bar': No such file or directory

     

    에러가 발생하지 않았습니다.

     

    하지만 물론 파일은 생성되지 않았습니다.

     

    왜 이렇게 되었을까요? 한 번 생각해보세요.

    번외: Go 언어에서 try...catch에 가까운 구현

    앞서 Go 언어에는 try...catch가 없다고 했지만, 비슷한 메커니즘인 panic이 있습니다.

    panic을 사용하여 에러를 발생시키는 방법을 알아봅시다.

    func errFunc(name string) {
            panic(name + " does not want to do anything.")
    }
    
    func main() {
            errFunc("Bob")
            fmt.Println("Success")
    }

     

    실행 결과는 다음과 같습니다.

    $ go run test.go
    panic: Bob does not want to do anything.

     

    panic으로 인해 프로그램이 강제 종료되었습니다.

     

    이제 이를 catch하기 위해 main 함수를 수정해봅시다.

    func main() {
            defer func() {
                    fmt.Printf("recovered from panic: ")
                    fmt.Println(recover())
            }()
            errFunc("Bob")
            fmt.Println("Success")
    }

     

    실행 결과는 다음과 같습니다.

    $ go run test.go
    recovered from panic: Bob does not want to do anything.

     

    성공적으로 catch되었습니다.

     

    하지만 "Success"는 출력되지 않았습니다.

     

    이는 main 함수 전체가 try 블록처럼 동작하기 때문입니다.

     

    이제 finally를 구현해봅시다.

    func main() {
            func() {
                    defer func() {
                            fmt.Printf("recovered from panic: ")
                            fmt.Println(recover())
                    }()
                    errFunc("Bob")
            }()
            fmt.Println("Success")
    }

     

    실행 결과는 다음과 같습니다.

    $ go run test.go
    recovered from panic: Bob does not want to do anything.
    Success

     

    성공적으로 구현되었습니다.

     

    하지만 코드가 다소 불편해 보입니다.

     

    실제로 사용할 때는 try 블록 내부를 별도의 함수로 선언하는 것이 좋습니다.

    for range

    Go 언어에도 foreach와 같은 구문이 있습니다. 그것이 range입니다.

    func main() {
            for p := range [...]int{2, 3, 5, 7, 11} {
                    fmt.Println(p)
            }
    }

     

    실행 결과는 다음과 같습니다.

    $ go run test.go
    0
    1
    2
    3
    4

     

    기대한 결과가 아닙니다.

     

    올바르게 수정해봅시다.

    func main() {
            for _, p := range [...]int{2, 3, 5, 7, 11} {
                    fmt.Println(p)
            }
    }

     

    실행 결과는 다음과 같습니다.

    $ go run test.go
    2
    3
    5
    7
    11

     

    작은 차이가 큰 버그를 유발하는 전형적인 예입니다.

    세미콜론 자동 삽입에 의한 문법 제약

    Go 언어로 복잡한 계산을 하고 싶다고 합시다.

     

    너무 복잡해서 한 줄에 다 담기지 않아, 다음과 같이 중간에 줄 바꿈을 넣었습니다.

    func main() {
            a := 2 + 3 + 5 + 7 + 11
                + 13 + 17 + 19
            fmt.Println(a)
    }

     

    실행 결과는 다음과 같습니다.

    $ go run test.go
    # command-line-arguments
    ./test.go:7:16: +13 + 17 + 19 evaluated but not used

     

    컴파일이 되지 않았습니다.

     

    그러나 아래와 같이 수정하면 잘 컴파일됩니다.

    func main() {
            a := 2 + 3 + 5 + 7 + 11 +
                 13 + 17 + 19
            fmt.Println(a)
    }

     

    왜 이런 현상이 발생하는지 설명하겠습니다.

     

    Go 언어는 각 행 끝에 세미콜론(;)을 자동으로 삽입합니다.

     

    두 번째 예제가 잘 작동한 이유는, 행 끝이 연산자로 끝날 때는 세미콜론을 삽입하지 않는다는 간단한 규칙에 따르기 때문입니다.

     

    이는 일부 ES6와는 큰 차이가 있습니다(사실 그렇게 단순하지는 않지만).

     

    즉, 간단한 제어 구조에서도 같은 문제가 발생할 수 있습니다.

    func main() {
            a := 2
            if a != 1    // 컴파일 오류
            {
                    fmt.Println("a is not 1")
            }
    }

    이름 공간과 패키지 이름

    Go 언어로 프로그램을 작성할 때, 처음에 package main을 기술합니다.

     

    이는 "이 파일은 main 패키지에 속한다"는 의미입니다.

     

    main 외의 패키지를 만들어 이름 공간을 분리하고 싶다면 어떻게 해야 할까요?

     

    Go 언어에서는 이 패키지 이름이 디렉터리 구조와 대응됩니다.

    $ tree
    .
    ├── main.go
    └── pub
        └── sub
            └── file.go
    
    2 directories, 2 files
    // pub/sub/file.go
    package sub
    
    import "fmt"
    
    func Sub() {
        fmt.Println("Hello from sub")
    }
    // main.go
    package main
    
    import "./pub/sub"
    
    func main() {
        sub.Sub()
    }

     

    위 예제를 보면 패키지 이름이 파일명이 아니라 디렉터리 이름과 대응된다는 것을 알 수 있습니다.

    interface{}가 nil이 되지 않는 문제

    Go 언어에서 모든 값을 저장할 수 있는 타입으로 interface{}가 있습니다.

     

    이 편리한 interface{}는 한 번 nil이 되면 아주 골칫거리가 됩니다.

    type A struct {}
    
    func f() *A {
            return nil
    }
    
    func main() {
            var i interface{} = nil
            var j interface{} = f()
            if i == nil {
                    fmt.Println("i is nil") // 출력됨
            }
            if j == nil {
                    fmt.Println("j is nil") // 출력되지 않음 (!)
            }
            if j.(*A) == nil {
                    fmt.Println("j is nil") // 출력됨
            }
            j = nil
            if j == nil {
                    fmt.Println("j is nil") // 출력됨
            }
    }

     

    Go 언어에서는 nil이 타입 정보를 포함하고 있습니다.

     

    따라서 (*A) 타입의 nil을 interface{} 타입의 nil로 변환하면 이상한 일이 발생합니다.

    교훈

    interface{} 타입의 nil 체크는 주의해야 합니다.

     

    참고:

    프리미티브 타입(chan)에서 변수 자동 초기화를 기대할 수 없음

    Go 언어에서는 대부분의 변수가 선언과 동시에 초기화됩니다. 하지만 chan의 경우는 어떻게 될까요?

    func main() {
            var ch chan int
            go func() {
                    ch <- 123
            }()
            fmt.Println(<-ch) // fatal error: all goroutines are asleep - deadlock! 출력됨
    }

    해결 방법

    make(chan int)를 사용합니다.

     

    chan 타입은 nil-able이기 때문에, 선언 직후에는 nil이 됩니다(아래 댓글 참조).

    Go Modules 사용 시 고유 URL 필요

    Go 1.11에서 Go Modules가 도입되었으며, Go 언어의 표준 기능으로 모듈을 쉽게 다룰 수 있게 되었습니다.

     

    Go Modules를 사용하려면 먼저 다음과 같이 go mod init 명령을 실행해야 합니다.

    $ go mod init https://example.com/testproj

     

    이 프로젝트에 대해 go build를 실행하면 testproj라는 이름의 실행 파일이 생성됩니다.

    Go의 툴체인에 --verbose에 해당하는 기능이 없음

    Go 언어의 툴체인은 빌드 시스템을 포함하여 모듈을 다룰 수 있는 다양한 기능을 제공합니다.

     

    툴이 여러 가지 일을 대신 처리해주는 것은 편리하지만, 예상치 못한 동작이 발생했을 때 이를 조사하는 데 시간이 걸립니다.

     

    이러한 상황에서 툴의 동작을 조사하기 위해 --verbose 옵션이 있으면 좋겠지만, 현재는 제공되지 않습니다.

     

    따라서 최악의 경우, Go 툴체인의 소스 코드까지 돌아가서 직접 분석해야 할 수도 있습니다.

    GoTo가 사용 가능

    Go 언어에서는 goto를 사용할 수 있습니다.

     

    에러 처리를 한 곳에 모을 때 유용하다고 합니다.

    func main() {
        for i := 0; i < 10; i++ {
            if i == 5 {
                goto Skip
            }
            fmt.Println(i)
        }
    Skip:
        fmt.Println("Skipped the loop")
    }

     

Designed by Tistory.