메서드

Method

Go는 클래스가 없다.

What? Go는 객체지향언어가 아니란 말인가?

미리 놀랄 필요는 없다. Go는 클래스가 없는 대신 보다 유연한 방식을 제공한다. 먼저 객체의 멤버 함수, 메서드를 Go에서 어떻게 구현하는지 확인해 보자.

메서드 리시버 (Method Receiver) 이해

생소한 이름이니 익숙한 곳에서 굳이 비슷한 역할을 찾아보자.

class MyClass
{
public:
	void MyFunction()
	{
		// This is MyFunction.
	}
};

C++ 클래스의 멤버 함수는 이렇게 선언과 정의를 동시에 하기도 하지만 나눌 수도 있다.

// my_class.h
class MyClass
{
public:
	void MyFunction();
};
// my_class.cpp
void MyClass::MyFunction()
{
	// This is MyFunction
}

MyClass의 멤버임을 알려주기 위해 MyFunction() 앞에 MyClass::를 붙였다. 바로 이 부분을 C++의 메서드 리시버라 생각하면 이해가 빠를 것이다.

Go에서 메서드를 만드는 방식은 다음과 같다.

MethodDecl     = "func" Receiver MethodName Signature [ FunctionBody ] .
Receiver       = Parameters .
Parameters     = "(" [ ParameterList [ "," ] ] ")" .
ParameterList  = ParameterDecl { "," ParameterDecl } .
ParameterDecl  = [ IdentifierList ] [ "..." ] Type .

다음과 같은 제약 및 특징이 있다.

  • Parameters는 단일, non-variadic이어야 한다.
  • 타입은 T 또는 *T일 수 있다.
  • 기본 타입이나 패키지 내에 존재하지 않는 타입에 대해서는 메서드를 정의할 수 없다.

C++ 예를 통해 보면 첫 번째와 세 번째는 어찌 보면 당연하고, 두 번째 특징이 조금은 특이해 보인다. 이는 Go의 메서드 리시버가 함수의 소속을 나타내는 역할뿐만 아니라 인수(parameter)의 역할도 동시에 하기 때문으로 아래에서 살펴보기로 하자.

구조체 메서드

C로 작성한 코드를 생각해보자. C는 클래스가 없다보니 OOP를 흉내내기 위해 여러 상태값을 구조체로 묶어 함수 호출 시 이 구조체를 인수로 함께 전달하도록 구성하는 경우가 많다.

typedef struct _MyStruct
{
    int state;
} MyStruct;

void LikeMethod(MyStruct *memberHolder, int modifiedState)
{
    memberHolder->state = modifiedState;
}

Go도 이런 방식이 불가능한 것은 아니다.

type MyStruct struct {
    state int
}

func LikeMethod(memberHolder *MyStruct, modifiedState int) {
    memberHolder.state = modifiedState
}

Python에서 self를 강제해 메서드를 만드는 방식과 유사해 보인다.

class MyClass:
	def myMethod(self, modifiedState):
        self.state = modifiedState

뒷맛이 개운치 않으니 이제 Go의 방식을 알아보자. 위 MethodDecl에서 확인한 것처럼 인수를 분리해 함수명 앞에 메서드 리시버를 지정하면 된다.

type MyStruct struct {
    state int
}

func (memberHolder *MyStruct) MyMethod(modifiedState int) {
    memberHolder.state = modifiedState
}

호출 방법은 이미 예상한 바로 그것이다.

var m = &MyStruct{10}
m.MyMethod(20)

메서드 리시버가 포인터를 받으니 구현이 틀렸다는 생각이 들었다면 일단 반은 박수.

메서드 리시버 타입

메서드 리시버의 특징으로 T, *T 타입을 모두 사용할 수 있다고 했다. 다음 예제를 보자.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package main

import "fmt"

type MyStruct struct {
	value int
}

func (s MyStruct) MyMethod1() {
	fmt.Println(s.value)
}

func (s *MyStruct) MyMethod2() {
	fmt.Println(s.value)
}

func (s *MyStruct) MyMethod3() {
	fmt.Println((*s).value)
}

func main() {
	var m = MyStruct{10}
	m.MyMethod1()
	(&m).MyMethod1()
	m.MyMethod2()
	(&m).MyMethod2()
	m.MyMethod3()
	(&m).MyMethod3()
}

포인터 타입을 엄격히 구분한다면 MyMethod2()는 잘못 구현된 것처럼 보인다. 14라인에서 포인터 접근을 기준이 되는 타입에 접근할 때와 동일하게 처리했기 때문이다. 또, 24, 25, 27라인도 역시 유사한 이유로 문제가 있는 것으로 보인다. 하지만 Go는 포인터에 대한 참조 연산자가 없는 대신 이정도는 알아서 처리한다. 즉, 23 ~ 28라인의 호출들은 모두 10을 화면에 표시한다. 차이는 call by name vs. call by address. Go의 메서드 리시버가 단순히 메서드의 소속을 나타낼 뿐 아니라 인수의 역할도 동시에 한다고 했던 이유이다.

다음 예들의 실행 결과를 확인해 보면 차이를 알 수 있을 것이다.

ex. 1

type MyStruct struct {
    value int
}

func (s MyStruct) MyMethod() {
    fmt.Println(s.value)
    s.value++
}

var m1 = MyStruct{10}
m1.MyMethod()
(&m1).MyMethod()
fmt.Println(m1.value)

var m2 = &MyStruct{10}
m2.MyMethod()
(*m2).MyMethod()
fmt.Println(m2.value)

ex. 2

type MyStruct struct {
    value int
}

func (s *MyStruct) MyMethod() {
    fmt.Println(s.value)
    s.value++
}

var m1 = MyStruct{10}
m1.MyMethod()
(&m1).MyMethod()
fmt.Println(m1.value)

var m2 = &MyStruct{10}
m2.MyMethod()
(*m2).MyMethod()
fmt.Println(m2.value)

기본 타입 메서드

기본 타입에는 메서드를 정의할 수 없다고 했지만 사실 완전히 불가능한 것은 아니다. 타입 선언을 이용해 기본 타입을 새로운 타입으로 만들면 된다. 주의할 것은 AliasDecl 방식은 별칭만 생긴다는 점.

type MyInt int

func (i MyInt) PrintMyInt() {
    fmt.Println(i)
}

func main() {
    var i MyInt = 10
    i.PrintMyInt()
}