9장 구조체와 인터페이스

Go에 내장된 데이터 타입만 이용해 프로그램을 작성하는 것도 가능하겠지만 일정 시점이 되면 그렇게 하기가 다소 지겨워질 것이다. 도형과 상호작용하는 다음과 같은 프로그램을 생각해 보자.

package main

import ("fmt"; "math")

func distance(x1, y1, x2, y2 float64) float64 {
    a := x2 – x1
    b := y2 – y1
    return math.Sqrt(a*a + b*b)
}
func rectangleArea(x1, y1, x2, y2 float64) float64 {
    l := distance(x1, y1, x1, y2)
    w := distance(x1, y1, x2, y1)
    return l * w
}

func circleArea(x, y, r float64) float64 {
    return math.Pi * r*r
}

func main() {
    var rx1, ry1 float64 = 0, 0
    var rx2, ry2 float64 = 10, 10
    var cx, cy, cr float64 = 0, 0, 5

    fmt.Println(rectangleArea(rx1, ry1, rx2, ry2))
    fmt.Println(circleArea(cx, cy, cr))
}

모든 좌표를 관리하려면 프로그램이 어떤 일을 하는지 파악하기 힘들어지고 실수가 발생할 가능성이 높아질 것이다.

9.1 구조체

이 프로그램을 더 낫게 만드는 한 가지 손쉬운 방법은 구조체(struct)를 사용하는 것이다. 구조체는 이름이 지정된 필드가 포함된 타입이다. 예를 들어 다음과 같이 Circle을 나타낼 수도 있다.

type Circle struct {
    x float64
    y float64
    r float64
}

type이라는 키워드는 새 타입임을 알려준다. 그다음에 타입의 이름(Circle)이 오고 struct라는 키워드가 현재 struct 타입을 정의하고 있음을 가리키고 중괄호 안에 필드 목록이 온다. 각 필드는 이름과 타입을 가진다. 함수와 마찬가지로 타입이 동일한 필드는 겹쳐서 쓸 수 있다.

type Circle struct {
    x, y, r float64
}

초기화

새 Circle 타입은 다양한 방법으로 인스턴스를 생성할 수 있다.

var c Circle

다른 데이터 타입과 마찬가지로 이렇게 하면 기본적으로 0으로 설정된 지역 Circle 변수가 생성된다. struct의 경우 0은 각 필드가 각 필드에 적절한 기본값(int의 경우 0, float의 경우 0.0, string의 경우 "", 포인터의 경우 nil 등)으로 설정된다는 의미다. 반면 new 함수를 사용할 수도 있다.

c := new(Circle)

이렇게 하면 모든 필드에 대한 메모리가 할당되고, 각 필드는 0 값으로 설정된 후 포인터(*Circle)가 반환된다. 하지만 각 필드에 값을 할당하고 싶을 때가 더 많다. 이렇게 하는 데는 두 가지 방법이 있다. 다음 코드를 보자.

c := Circle{x: 0, y: 0, r: 5}

또는 필드가 정의된 순서를 알고 있을 경우 필드명을 생략할 수 있다.

c := Circle{0, 0, 5}

필드

. 연산자를 이용하면 필드에 접근할 수 있다.

fmt.Println(c.x, c.y, c.r)
c.x = 10
c.y = 5

circleArea 함수가 Circle을 사용하도록 수정해 보자.

func circleArea(c Circle) float64 {
    return math.Pi * c.r * c.r
}
main 함수에서는 다음과 같이 수정한다.
c := Circle{0, 0, 5}
fmt.Println(circleArea(c))
여기서 한 가지 기억해야 할 점은 Go에서는 항상 인자가 복사된다는 것이다. circleArea 함수 안의 필드 중 하나를 바꾸려고 하더라도 원본 변수는 변경되지 않을 것이다. 이런 까닭에 다음과 같은 식으로 함수를 작성하곤 한다.
func circleArea(c *Circle) float64 {
    return math.Pi * c.r * c.r
}

그리고 main 함수는 다음과 같이 수정한다.

c := Circle{0, 0, 5}
    fmt.Println(circleArea(&c))

9.2 메서드

수정한 코드가 이 코드의 첫 번째 버전보다 더 낫긴 하지만 메서드(method)라고 하는 특별한 유형의 함수를 이용해 코드를 대폭 개선할 수 있다.

func (c *Circle) area() float64 {
    return math.Pi * c.r * c.r
}

func 키워드와 함수명 사이에 "수신자(receiver)"를 추가했다. 수신자는 이름과 타입이 있다는 점에서 매개변수와 비슷하지만 이런 식으로 함수를 생성하면 . 연산자를 이용해 해당 함수를 호출할 수 있다.

fmt.Println(c.area())

이 코드는 훨씬 더 읽기 쉽고 더는 & 연산자가 필요하지 않으며(Go는 자동으로 이 메서드에 Circle에 대한 포인터를 전달해야 한다는 사실을 안다) 이 함수는 Circle하고만 사용할 수 있기 때문에 함수의 이름을 단순히 area로 바꿀 수 있다. 직사각형에 대해서도 같은 작업을 해보자.

type Rectangle struct {
    x1, y1, x2, y2 float64
}
func (r *Rectangle) area() float64 {
    l := distance(r.x1, r.y1, r.x1, r.y2)
    w := distance(r.x1, r.y1, r.x2, r.y1)
    return l * w
}

main 함수는 다음과 같이 수정한다.

r := Rectangle{0, 0, 10, 10}
fmt.Println(r.area())

포함 타입

구조체의 필드는 보통 has-a 관계를 나타낸다. 예를 들어, Circleradius를 가지고 있다. 사람 구조체를 생각해 보자.

type Person struct {
    Name string
}
func (p *Person) Talk() {
    fmt.Println("안녕, 내 이름은 ", p.Name, "야")
}

그리고 새로운 Android 구조체를 만들고 싶다. 그럼 다음과 같이 할 수도 있다.

type Android struct {
    Person Person
    Model string
}

이렇게 해도 동작하긴 하지만 안드로이드가 사람을 가지고 있다라기보다는(Android has a Person) 안드로이드가 사람이라고 말할 것이다(Android is a Person). Go에서는 포함 타입(embedded type)을 이용해 이 같은 관계를 지원한다. 익명 필드(anonymous field)라고도 알려져 있는 포함 타입은 다음과 같은 형태를 취한다.

type Android struct {
    Person
    Model string
}

보다시피 타입(Person)을 사용하고 이름을 주지 않았다. 이런 식으로 정의하면 Person 타입명을 이용해 구조체에 접근할 수 있다.

a := new(Android)
a.Person.Talk()

하지만 Android에서도 Person 메서드를 직접 호출할 수 있다.

a := new(Android)
a.Talk()

is-a 관계는 이처럼 직관적으로 동작한다. 즉, 사람은 이야기할 수 있고, 안드로이드는 사람이며, 따라서 안드로이드는 이야기할 수 있다.

9.3 인터페이스

앞에서 Circlearea 메서드와 똑같이 Rectangle에도 area라는 메서드를 지정할 수 있다는 점을 눈치챘을지도 모르겠다. 이것은 우연이 아니다. 현실의 삶에서도 그렇고 프로그래밍에서도 그렇듯이 이러한 관계는 보편적이다. Go에서는 인터페이스로 알려진 타입을 통해 이러한 우연한 유사성을 명시적으로 만들어주는 수단을 제공한다. 다음은 Shape 인터페이스의 예다.

type Shape interface {
    area() float64
}

구조체와 마찬가지로 인터페이스는 type 키워드 다음에 이름, 그리고 interface라는 키워드를 지정해서 만든다. 하지만 필드를 정의하는 대신 "메서드 집합"을 정의한다. 메서드 집합은 어떤 타입에서 인터페이스를 "구현(implement)"하기 위해 반드시 포함하고 있어야 할 메서드의 목록에 해당한다.

앞서 설명한 경우에는 RectangleCircle이 모두 float64를 반환하는 area 메서드를 가지고 있으므로 두 타입 모두 Shape 인터페이스를 구현하는 셈이다. 인터페이스 자체로는 그다지 유용하지 않지만 인터페이스 타입을 함수의 인자로 사용할 수 있다.

func totalArea(shapes ...Shape) float64 {
    var area float64
    for _, s := range shapes {
        area += s.area()
    }
    return area
}

그러면 이 함수를 다음과 같이 호출할 수 있다.

fmt.Println(totalArea(&c, &r))

인터페이스는 필드로도 사용할 수 있다.

type MultiShape struct {
    shapes []Shape
}

심지어 area 메서드를 부여해 MultiShape 자체를 Shape으로 전환할 수도 있다.

func (m *MultiShape) area() float64 {
    var area float64
    for _, s := range m.shapes {
        area += s.area()
    }
    return area
}

이제 MultiShapeCircle이나 Rectangle, 심지어 다른 MultiShape조차도 포함할 수 있다.

연습 문제

  1. 메서드와 함수의 차이점은 무엇인가?

  2. 일반적인 이름이 지정된 필드 대신 포함 익명 필드를 사용하는 이유는 무엇인가?

  3. Shape 인터페이스에 도형의 둘레를 계산하는 perimeter라는 메서드를 추가하라. CircleRectangle에 대해 이 메서드를 구현하라.

← 이전다음 →