Go 파헤치기 - 타입 시스템, 인터페이스, 타입 임베딩

 

1. 들어가며

 

Go는 정적 타입 프로그래밍 언어입니다. 개인적으로 동적 프로그래밍 언어에 대해서 python과 Javascript로 프로젝트를 진행해본 경험으로는 규모가 커질수록 유지 보수가 까다로워진다는 인상을 받았습니다. 그런 점에서 Go의 정적 타입이 반가웠던 기억이 있습니다.

 

그리고 Go에도 인터페이스가 있습니다. 개인적으로 몹시 좋아하는 기능인만큼 반가웠는데, 그 생김새는 사뭇 다릅니다. 그리고 상속이 없습니다. 그 대신 인터페이스, 타입 임베딩 등을 제공하고 있습니다.

 

그럼 Go의 타입 시스템, 인터페이스, 타입 임베딩은 어떠한지 파헤쳐보도록 하겠습니다.

 

2. 사용자 정의 타입

 

Go는 정적 타입 프로그래밍 언어인만큼 컴파일러가 프로그램의 모든 값에 대한 타입을 알고자 합니다. 따라서 타입 오류에 대해 컴파일 타임에서 오류를 잡아낼 수 있습니다.

 

이러한 타입에는 원시 타입사용자 정의 타입이 있습니다. 원시 타입은 여타 언어와 유사하므로 따로 짚고 넘어가지는 않겠습니다.

 

Go에는 따로 클래스가 없는 대신 C에서 자주 보았던 구조체(struct)가 있는데요. 이러한 구조체를 통해 사용자 정의 타입을 선언할 수 있습니다. 그 외에도 기존 유형을 가져와 새 유형으로 사용하는 방식도 가능합니다. (c에서 int32 같은 것을 가져와 다른 이름으로 사용한 경험이 있을 것입니다!)

 

- 구조체 선언 방법

type user struct {
    name       string
    email      string
    ext        int
    privileged bool
}

// 위는 아래와 같이 선언될 수 있습니다.
var bill user

 

C의 구조체 선언 방식과 유사하지만 순서가 조금 다릅니다. 

typedef struct _student
{
char name[20]           // 이름
int scoreKOR;            // 국어점수
int scoreMAT;            // 수학점수
int scoreENG;            // 영어점수
int scoreSCI;             // 과학점수
char *comment;        // 평가
} STUDENT;

 

위는 Go의 선언 방식, 아래는 C의 선언 방식입니다. 구조체 이름을 선언하는 위치나 일반 변수처럼 선언하는 방식이 따로 없이 가능하다는 점 등이 차이가 있습니다.

 

- 구조체 초기화

lisa := user{
    name:       "Lisa",
    email:      "lisa@email.com",
    ext:        123,
    privileged: true,
}

// 순서가 중요하지만 다음과 같이도 가능합니다.
lisa := user{"Lisa", "lisa@email.com", 123, true}

 

구조체는 다른 구조체의 필드도 될 수 있습니다. 그럴 경우 다음과 같이 선언하고 초기화할 수 있습니다.

// admin은 권한이 있는 관리자 사용자를 나타냅니다. 
type admin struct { 
person user 
level string 
}

// 위와 같은 중첩된 구조체는 다음과 같이 선언됩니다.
fred := admin{
    person: user{
        name:       "Lisa",
        email:      "lisa@email.com",
        ext:        123,
        privileged: true,
    },
    level: "super",
}

 

- 기존 형식 재정의

struct를 통해 새로운 객체 형식을 만드는 것뿐만 아니라 기존 형식에 대해 재정의도 할 수 있습니다.

type Duration int64

 

하지만 여기서 int64 Duration Go에서 다르게 판단됩니다. 즉 다음과 같은 코드는 컴파일 오류를 발생시킵니다.

 

package main

type Duration int64

func main() {
    var dur Duration
    dur = int64(1000) // compile error!!!
}

 

즉 둘이 가지고 있는 형태는 같더라도 컴파일러는 다르게 판단하며 암묵적 형변환을 자동으로 해주지 않습니다.

 

- 메서드

struct에는 동작을 추가할 수 있습니다. 메서드는 func 키워드와 함수 이름 사이에 선언된 추가 매개 변수를 포함하는 함수입니다.

package main
 
//Rect - struct 정의
type Rect struct {
    width, height int
}
 
//Rect의 area() 메소드
func (r Rect) area() int {
    return r.width * r.height   
}
 
func main() {
    rect := Rect{10, 20}
    area := rect.area() //메서드 호출
    println(area)
}

 

func (r Rect) area() int처럼 func 키워드와 함수 이름 사이의 매개변수는 수신자라고 하며 함수를 지정한 유형에 바인딩하는 역할을 합니다. 이 경우 이 함수를 메서드라고 합니다.

즉 함수 이름과 func 키워드 사이에 수신자가 있다면 그것은 메서드가 됩니다.

 

이 수신자에는 두 가지 유형이 있습니다. 

값 수신자와 포인터 수신자입니다.

 

- 값 수신자

func (u user) notify()

 

값 수신자는 항상 메서드 호출에 사용된 값의 복사본에 대해 작동합니다. 원래의 값에는 영향을 주지 않습니다. 그리고 메서드 호출 시 구조체가 복사되므로 추가적인 메모리 할당이 생길 수 있습니다.

 

- 포인터 수신자

func (u *user) changeEmail(email string)

 

값 수신자와는 반대로 메서드를 호출하기 위해 사용된 값을 메서드가 공유합니다.

즉 메서드에서 이루어진 변경사항은 메서드 호출이 리턴된 이후에도 계속 유지됩니다.

 

포인터 인수 함수와 다르게 포인터 리시버는 값이나 포인터를 리시버로 받아들입니다. 예를 들어

 

type Vertex struct {
	X, Y float64
}

func (v *Vertex) Scale(f float64) {
	v.X = v.X * f
	v.Y = v.Y * f
}

func ScaleFunc(v *Vertex, f float64) {
	v.X = v.X * f
	v.Y = v.Y * f
}

라는 코드가 있다고 해볼까요? 그렇다면

 

var v Vertex
ScaleFunc(v, 5) // 컴파일 에러
ScaleFunc(&v, 5)	// OK

로 위 코드는 컴파일 에러를 발생시키고 아래 코드는 잘 작동합니다. 함수에서 포인터 인자를 요구했기 때문에 포인터를 넘겨줘야 하기 때문입니다.

 

하지만 다음 코드는 작동합니다.

var v Vertex
v.Scale(5)  // OK
p := &v
p.Scale(10) // OK

 

, Scale 메소드가 포인터 리시버를 가졌기 때문에 편의상 GO v.Scale(5) 라는 것을 (&v).Scale(5) 로 해석합니다.

 

그렇다면 값 리시버와 포인터 리시버는 어떤 곳에 각각 사용해야 할까요?

 

새로운 타입을 정의 했다고 가정해볼까요? 이 타입의 값에 무언가를 더하거나 삭제한다면,

  • 새로운 값이 생성되어야 하는가?
  • 기존의 값이 변경되어야 하는가?

이 질문에 대한 답이 새로운 값이라면 메서드에 값 수신자, 기본 값의 변경이 답이라면 포인터 수신자를 사용하는 것이 좋습니다.

따라서, 만약 메서드 내에서 구조체의 상태를 변경하는 작업이 필요한 경우에는 포인터 리시버를 사용하는 것이 좋습니다. 반대로, 구조체의 상태를 변경하지 않는 읽기 전용 작업을 수행하는 경우에는 값 리시버를 사용하는 것이 좋을 수 있습니다.

 

다만 값 또는 포인터 수신기를 사용할지 여부는 메서드가 수신 값을 변형하는지 여부에 따라서만 결정해서는 안 됩니다. 결정은 타입의 특성에 따라야 합니다. 이 지침에 대한 한 가지 예외는 인터페이스 값으로 작업할 때 값 수신자가 제공하는 유연성이 필요한 경우입니다. 이러한 경우 유형의 특성이 기본이 아니더라도 값 수신자를 사용하도록 선택할 수 있습니다. 이는 인터페이스 값이 내부에 저장된 값에 대한 메서드를 호출하는 방법의 메커니즘에 전적으로 기반합니다.

 

3. 인터페이스

Golang의 Interface는, 하나의 타입이 구현해낼 수 있는 Method Signature의 모음이다.
Method Signature는 Method를 구현하기 위해 필요한 이름(name)과 인자(parameter)를 Method Signature라고 한다.

 

Interface의 가장 주된 용도는, Method name, parameter, return value를 명시하는것입니다.

 

- Interface 사용 예시

type Shape interface {
    area() float64
    perimeter() float64
}

//Rect 정의
type Rect struct {
    width, height float64
}
 
//Circle 정의
type Circle struct {
    radius float64
}
 
//Rect 타입에 대한 Shape 인터페이스 구현 
func (r Rect) area() float64 { return r.width * r.height }
func (r Rect) perimeter() float64 {
     return 2 * (r.width + r.height)
}
 
//Circle 타입에 대한 Shape 인터페이스 구현 
func (c Circle) area() float64 { 
    return math.Pi * c.radius * c.radius
}
func (c Circle) perimeter() float64 { 
    return 2 * math.Pi * c.radius
}

func main() {
    r := Rect{10., 20.}
    c := Circle{10}
 
    showArea(r, c)
}
 
func showArea(shapes ...Shape) {
    for _, s := range shapes {
        a := s.area() //인터페이스 메서드 호출
        println(a)
    }
}

 

 

위 코드를 자세히 살펴보시면 Java나 C#과 사뭇 다른 모습을 볼 수 있습니다. Go에서는 한 타입이 인터페이스를 구현하는지 언급하지 않습니다.(코드에 적지 않습니다) 한 타입이 인터페이스내에서 메소드를 구현한다면, 그 타입은 인터페이스를 구현하는 것입니다.

 

if it walks like a duck, swims like a duck and quacks like a duck, then it’s a duck

 

라는 말로 표현한 글이 있었는데 꽤 적절한 표현인 것 같습니다.

 

마찬가지로 인터페이스를 구현하도록 강제할 수도 없습니다. 예를 들어 Java의 경우 인터페이스를 구현할 경우 해당 인터페이스의 메소드를 구현 클래스에서 구현하도록 '강제'합니다. 하지만 Golang에서는 명시적으로 언급하지 않으므로 그럴 방법이 없습니다. 물론 구현하지 않은 인터페이스일 경우 컴파일 타임에서 오류를 확인할 수 있습니다. (GoLand 실험) 꽤 독특한 방식이라는 생각이 들면서도 상속을 없애고 구성만으로, 그리고 구성도 원래 의미인 모듈 방식을 극대화한 인상을 받는 것 같습니다.

 

테스트 결과 오류 확인

 

- 인터페이스 파헤치기

 

그럼 더 자세히 살펴 보겠습니다. 각 인터페이스 구현에 따라 어떤 것이 컴파일 가능하고 불가능한지 살펴보고자 합니다.

 

1. 컴파일 가능한 코드:

func (s S) m() {}

var i I = S{}

 

2. 컴파일 가능한 코드:

func (s S) m() {}

var i I = &S{}

 

3. 컴파일 가능한 코드:

func (s *S) m() {}

var i I = &S{}

 

4. 컴파일 불가능한 코드:

func (s *S) m() {}

var i I = S{} // compile error

 

1번과 2번 코드는 리시버 타입이 non-pointer로 선언되어 있습니다. 따라서 메서드 m을 호출하면 인터페이스 타입의 변수 i가 담고 있는 값 S의 복사본이 전달됩니다.

 

3번과 4번 코드는 리시버 타입이 포인터라는 것만 제외하면 1, 2번과 동일합니다. 그러므로 S를 복사하는 대신 S의 주소가 함수 호출 시 전달됩니다.

주소를 참조하기 위해서는 & 연산자를 이용합니다. 하지만 4번 코드의 변수 i는 주소를 참조할 수 없습니다. 인터페이스 타입의 변수가 담고 있는 구현체의 값은 addressable하지 않기 때문입니다.

 

 

추가 참고 자료들이며 읽어보면 이해를 높이기 좋습니다 :)

https://jusths.tistory.com/159

 

Russ Cox 의 Interface 블로그 포스팅 분석

개요 Russ Cox 의 인터페이스 글을 기반으로 Interface 에 대해 좀 더 깊이 들여다 본다. 링크: https://research.swtch.com/interfaces 사용법 예제 링크: https://play.golang.org/p/AEHmlYtqkAy 1) ReadCloser 라는 인터페이스

jusths.tistory.com

https://research.swtch.com/interfaces

 

research!rsc: Go Data Structures: Interfaces

Go Data Structures: Interfaces Posted on Tuesday, December 1, 2009. Go's interfaces—static, checked at compile time, dynamic when asked for—are, for me, the most exciting part of Go from a language design point of view. If I could export one feature of

research.swtch.com

https://www.airs.com/blog/archives/277

 

Go Interfaces – Airs – Ian Lance Taylor

One of the interesting aspects of the Go language is interface objects. In Go, the word interface is overloaded to mean several different things. Every type has an interface, which is the set of methods defined for that type. This bit of code defines a str

www.airs.com

 

4. 타입 임베딩

상속은 다른 언어에서 많이 사용하고 있는 방식입니다. 코드 재활용을 많이 할 수 있다는 장점이 있지만 단점도 명확한 편인데, 부모의 변화에 영향을 받는 깨지기 쉬운 상태의 클래스가 된다는 점입니다. (Effective Java) 이로 인해 캡슐화를 위반하거나 Open-close 원칙을 위반하는 경우가 많이 생깁니다. 따라서 Go는 오로지 구성(Go Embedding) 기능만을 지원하도록 설계되었습니다.

 

type Base struct {
	Name  string
	Value int
}

type EmbedsBase struct {
	Base
	Other string
}

 

위처럼 다른 struct를 내부 필드로 넣는다고 해서 임베딩이라는 이름이 붙었습니다.

 

임베딩에는 크게 두 가지 방법이 존재합니다. 하나는 타입을 임베딩 하는 방식이고 다른 하나는 포인터를 임베딩하는 방식입니다. 

 

1) 타입 임베딩

type Base struct {
	Name  string
	Value int
}

type EmbedsBase struct {
	Base
	Other string
}

EmbedsBase는 Base의 Name과 Value를 모두 가지는 새로운 타입입니다.

 

2) 포인터 임베딩

type Base struct {
	Name  string
	Value int
}

type EmbedsPointerToBase struct {
	*Base
	Other string
}

 

EmbedsPointerToBase는 Base의 포인터를 변수로 갖는 새로운 타입입니다.

 

각각 어떤 상황에서 사용하면 좋을까요?

두 방식은 객체 생성 및 전달에서 차이가 있으며, 객체의 내부 변수에 어떻게 접근하고 싶은가에 따라 선택적으로 사용하면 된니다.

Base의 메서드가 Base 의 값을 제어하고, 이때 이 Base를 임베딩한 EmbedsBase 객체를 값으로 넘기는 특수한 함수를 가정하면, 이 함수 내부에서 Base를 조작하는 것은 외부에 영향을 주지 못합니다. 하지만, 포인터를 임베딩한 EmbedsPointerToBase 을 사용하면, 값을 넘기더라도 ControlEmbedsBaseValue함수로 EmbedsPointerToBase의 Name 변수를 제어할 수 있습니다.

 

Go는 함수를 실행하는 순간에 외부에서 전달받은 변수를 복사하여 함수 안에 새롭게 생성합니다. 만약 전달받은 값이 포인터이고, 이 포인터를 통해서 변수를 조작했다면, 외부 변수에 영향을 줄 것입니다. 반대로 전달받은 값이 단순한 변수라면, 이 변수를 조작 하더라도 외부 변수에 영향을 줄 수 없습니다. 이를 이해하고 상황에 따라 선택적으로 사용하면 됩니다.

 

- 임베딩 파헤쳐보기

package main

import (
    "fmt"
)

type S1 struct{
    f1 string
}

type S2 struct{
    S1
    f2 string
}   

func (s *S1) Say(){
    fmt.Println("1")
}   

func (s *S2) Say(){
    fmt.Println("2")
}       

type S3 S2

func main() {
    var s3 S3
    var s2 S2
    s2.Say()
    s3.Say()
}

// 출처: https://stackoverflow.com/questions/45898990/understanding-struct-embedding

 

위 코드는 어떤 것을 출력할까요? 

 

답은

2

1

입니다.

 

type S3 S2

S3 S2는 동일한 형태이지만 type은 다릅니다. 그리고 이것은 상속이 아니므로 s2에 있는 메소드를 s3에서도 사용할 수 있는 것은 아닙니다.

 

그럼에도

s3.Say()

는 컴파일 에러를 발생시키지 않고 실행되고 있습니다. 이는 S3 내부에 S1이 있고 이러한 inner type의 경우 승격이 되어 해당 필드와 함수들을 s3.s1.Say()처럼 쓰는 것이 아닌 s3.Say()로 사용할 수 있기 때문입니다.

 

다만 S2처럼 동일한 인터페이스가 내부 타입과 외부 타입 모두 구현되어 있다면 외부 타입이 우선적으로 실행이 됩니다 내부 타입을 실행하고 싶다면 s2.s1.say()의 방식으로 모두 작성해주어야 합니다.