안녕하세요?
오늘은 Go 언어 인터페이스 구현 패턴과 함께 type, 메소드 그리고 인터페이스의 기본적인 설명을 추가하여 아래와 같은 것들을 살펴볼 예정입니다.
- Go 언어에서의 인터페이스 부분 구현 패턴
- struct에 interface를 포함시켜 동적으로 교체하는 패턴
먼저 type과 메소드, 기본적인 인터페이스 구현 방법에 대해 잠시나마 복습해보겠습니다.
** 목 차 **
type으로 타입을 선언하기
우선, Go 언어에서의 타입 선언 방법입니다.
Go 언어를 처음 시작하는 분들 중에서 type
의 사용 방법을 제한적으로만 이해하고 있는 분들이 많습니다.
알다시피, type
은 타입을 선언하기 위해 사용하는 키워드입니다.
아래와 같이, 구조체 타입이나 인터페이스 타입의 선언 시에 사용하는 것이 일반적입니다.
// 구조체 타입 선언
type Foo struct {
// 필드 리스트
}
// 인터페이스 타입 선언
type Bar interface {
// 메소드 리스트
}
golang.org의 The Go Programming Language Specification을 보면, type
을 사용한 타입 선언의 문법은 아래와 같이 정의되어 있습니다.
TypeDecl = "type" ( TypeSpec | "(" { TypeSpec ";" } ")" ) .
TypeSpec = identifier Type .
Type = TypeName | TypeLit | "(" Type ")" .
TypeName = identifier | QualifiedIdent .
TypeLit = ArrayType | StructType | PointerType | FunctionType | InterfaceType | SliceType | MapType | ChannelType .
좀 더 이해하기 쉽게 쓰면 아래와 같습니다.
type 식별자 타입
'식별자'는 여기에서 선언하는 타입의 이름입니다.
'타입'은 타입명 또는 타입 리터럴, ()
로 둘러싸인 타입입니다.
타입명은 int나 string과 같은 내장 타입의 타입명뿐만 아니라, 각 패키지에서 type을 사용하여 선언된 기존의 독자적인 타입의 타입명도 포함합니다.
즉, type
을 사용하면 기존의 타입에 새로운 이름을 붙일 수 있습니다.
아래와 같이 선언한 경우, 기본적으로는 int
와 같은 방식으로 작동합니다.
그러나, int
와 Hex
는 다른 타입이기 때문에 연산을 하거나 int
타입의 값을 Hex
타입의 변수에 넣을 경우 타입 캐스팅이 필요합니다.
type Hex int
타입 리터럴이란 아래와 같은 타입을 리터럴로 쓴 것입니다.
// 배열 타입
[10]int
// 구조체 타입
struct {
// 필드 리스트
}
// 포인터 타입
*int
// 함수 타입
func(s string) int
// 인터페이스 타입
interface {
// 메소드 리스트
}
// 슬라이스 타입
[]int
// 맵 타입
map[string]int
// 채널 타입
chan bool
이러한 타입 리터럴에 '식별자'로 지정한 타입명을 새로 붙일 수 있습니다.
즉, 구조체 타입이나 인터페이스 타입뿐만 아니라, 함수 타입이나 슬라이스 타입, 맵 타입, 채널 타입의 타입 리터럴에도 타입명을 붙일 수 있습니다.
실제로, http 패키지의 http.HandlerFunc는 아래와 같이 정의된 함수 타입입니다.
type HandlerFunc func(w http.ResponseWriter, r *http.Request)
모든 것을 구조체 타입으로 선언한 게 보통이지만, type
을 사용한 타입 선언은 더 유연한 사용 방법이 가능하므로, 꼭 다양한 시도를 해보시기 바랍니다.
메소드
메소드는 다음과 같이 리시버와 메소드 이름, 함수 본문을 지정하여 정의합니다.
이 경우 p가 리시버가 됩니다.
func (p *Person) String() string {
return fmt.Sprintf("%s %s (%d)", p.FirstName, p.LastName, p.Age)
}
위의 메소드는 다음과 같은 구조체의 포인터 타입 메소드로 정의되어 있다고 가정합니다.
type Person struct {
FirstName string
LastName string
Age int
}
와 같이, 구조체 타입(또는 구조체의 포인터 타입)에 메소드를 설정할 수 있습니다.
그러나 메소드는 구조체 타입이나 구조체의 포인터 타입에만 정의할 수 있는 것은 아닙니다.
인터페이스 타입이 아니라면, 어떤 타입에도 메소드를 정의할 수 있습니다.
예를 들어, 위에서 언급한 Hex 타입에 메소드를 정의해 봅시다.
type Hex int
func (h Hex) String() string {
return fmt.Sprintf("0x%x", int(h))
}
int
타입을 새로운 Hex
타입으로 선언하고, String
이라는 메소드를 설정할 수 있었습니다.
물론, 마찬가지로 함수에도 메소드를 설정할 수 있습니다.
실제로, http.HandlerFunc
는 다음과 같은 메소드를 정의하고 있습니다.
type HandlerFunc func(w ResponseWriter, r *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}
http.HandlerFunc
의 ServeHTTP
메소드는 리시버 자신을 함수 호출하는 메소드입니다만, 왜 이러한 메소드를 선언할 필요가 있는지는 나중에 살펴보겠습니다.
기본적인 인터페이스의 구현
앞서 언급한 바와 같이, 인터페이스 타입은 다음과 같이 선언됩니다.
type TypeName interface {
// 메소드 리스트
Method1()
Method2()
}
인터페이스 타입의 선언 시에 지정한 메소드 리스트의 메소드를 모두 구현함으로써, 인터페이스를 구현할 수 있습니다.
Java
언어에서와 같이, implements
등을 사용하여 명시적으로 구현할 필요는 없습니다.
여기까지 여러 번 나온 각 타입의 String
메소드는, fmt.Stringer
인터페이스를 구현하고 있는 것입니다.
참고로, fmt.Stringer
는 다음과 같이 선언되어 있습니다.
type Stringer interface {
String() string
}
예를 들어, 위에서 언급한 Hex
타입은 String
메소드를 가지고 있기 때문에, fmt.Stringer
인터페이스를 구현한 것입니다.
따라서, Hex
타입은 fmt.Stringer
인터페이스로서 행동할 수 있습니다.
fmt.Stringer
인터페이스로서 행동한다는 것은, fmt.Stringer
타입의 변수에 할당하거나, 함수의 인자로 전달할 수 있다는 것을 의미합니다.
var stringer fmt.Stringer
// int 타입의 100을 Hex 타입으로 캐스트하고, fmt.Stringer 타입의 변수에 할당하고 있습니다.
stringer = Hex(100)
참고로, 자주 보게 되는 interface{}
타입은, 메소드 리스트가 없는 인터페이스 타입의 타입 리터럴입니다.
메소드 리스트가 없다는 것은, 메소드를 하나도 구현하지 않아도, interface{}
인터페이스를 구현한 것이 되므로, interface{}
타입의 변수나 인자에는, 어떤 타입의 값이든 할당하거나 전달할 수 있습니다.
지금까지 type, 메소드, 인터페이스의 기본적인 구현 방법에 대해 설명했습니다.
지금부터는 좀 더 응용적인 방법으로 인터페이스를 구현해 보겠습니다.
함수에 인터페이스 구현하기
앞서 언급한 것처럼, 함수에도 메소드를 포함시킬 수 있습니다.
예를 들어, http.HandlerFunc는 다음과 같이 ServeHTTP 메소드를 가지고 있었습니다.
type HandlerFunc func(w ResponseWriter, r *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}
http 패키지에서는, http.Handler라는 인터페이스를 정의하고 있습니다.
type Handler interface {
ServeHTTP(w ResponseWriter, r *Request)
}
그리고 사실 http.HandlerFunc 타입은 같은 인자와 반환값을 가진 함수에, http.Handler 인터페이스를 구현하기 위해 선언된 타입이었습니다.
그래서 메소드 안에서 리시버 자신의 함수 호출을 하고 있었습니다.
함수를 다음과 같이 캐스트하여, 인터페이스 타입으로 변수에 할당할 수 있습니다.
이때, f는 http.Handler를 구현하고 있기 때문에, http.Handle의 두 번째 인자로 전달할 수 있습니다.
f := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "hello, world")
})
// func Handle(pattern string, handler Handler)
http.Handle("/", f)
또한, http 패키지에는 이 과정을 더 간단하게 할 수 있는 http.HandleFunc라는 함수가 준비되어 있습니다.
보통은 이 함수를 사용하는 것이 일반적입니다.
func HandleFunc(pattern string, handler func(ResponseWriter, *Request))
구조체에 포함시켜 인터페이스 구현하기
구조체에는, 익명 필드로 지정된 타입의 값을 포함시킬 수 있습니다.
포함된 타입의 값의 필드나 메소드는, 마치 포함된 구조체의 필드나 메소드처럼 호출할 수 있습니다.
예를 들어, 다음과 같이 Name의 포인터 타입에 String 메소드를 포함시키고, Person 타입에 포함시켜 봅니다.
Person 타입은 String 메소드를 가지고 있지 않지만, 포함된 *Name 타입이 String 메소드를 가지고 있기 때문에, p.String()처럼 호출할 수 있습니다.
type Name struct {
FirstName string
LastName string
}
func (n *Name) String() string {
return fmt.Sprintf("%s %s", n.FirstName, n.LastName)
}
type Person struct {
// *Name 타입의 값을 포함시킴
*Name
Age int
}
func main() {
n := &Name{
FirstName: "Jisung",
LastName: "Park",
}
p := &Person{
// *Name 타입의 n을 포함시킴
Name: n,
Age: 20,
}
fmt.Println(p.String())
}
이때, *Name
타입은 String
메소드를 가지고 있기 때문에, fmt.Stringer
인터페이스를 구현하고 있습니다.
그러나 사실 *Name
타입을 Person
타입에 포함시킨 것으로 인해, Person
타입과 *Person
타입도 fmt.Stringer
인터페이스를 구현하게 되었습니다.
즉, 위의 변수 p
는 다음과 같이 fmt.Stringer
타입의 변수에 할당할 수 있습니다.
var stringer fmt.Stringer = p
fmt.Println(stringer.String())
인터페이스의 부분 구현을 위한 임베딩 사용
인터페이스를 정의할 때 여러 메소드를 구현하도록 지정할 수 있습니다.
예를 들어, 다음과 같은 인터페이스를 정의한다고 가정해 봅시다.
type Person interface {
// 호칭
Title() string
// 이름
Name() string
}
위에서 언급한 임베딩을 통한 인터페이스 구현을 응용하여, 인터페이스에 지정된 메소드 리스트의 일부를 구조체 타입에 임베딩된 타입으로 구현할 수 있습니다.
다음의 *person
타입은 Name
메소드를 가지고 있습니다.
그러나 Title
메소드는 가지고 있지 않기 때문에, Person
인터페이스를 구현하고 있다고 볼 수 없습니다.
type person struct {
firstName string
lastName string
}
func (p *person) Name() string {
return fmt.Sprintf("%s %s", p.firstName, p.lastName)
}
그러나 *person
타입을 Title
메소드를 구현한 구조체에 임베딩함으로써, Person
인터페이스를 구현할 수 있습니다.
다음 예에서는 *female
타입과 *male
타입에 Title
메소드를 구현하고 있습니다.
각 타입에는 *person
타입이 임베딩되어 있기 때문에 Name
메소드도 구현하고 있습니다.
따라서 *female
타입과 *male
타입은 Person 인터페이스를 구현하고 있습니다.
type Gender int
const (
Female Gender = iota
Male
)
type female struct {
*person
}
func (f *female) Title() string {
return "Ms."
}
type male struct {
*person
}
func (m *male) Title() string {
return "Mr."
}
Go 언어에서는 소문자로 시작하는 타입은 해당 패키지 내에서만 사용할 수 있습니다.
따라서 female 타입과 male 타입은 다른 패키지에서 숨겨져 있습니다.
다른 패키지에서는 이러한 타입들을 의식하지 않고 Person 인터페이스로 사용할 수 있는 것이 바람직합니다.
Person 인터페이스를 구현한 값을 반환하는 NewPerson 함수를 생각해 봅시다.
인자로 성별(gender)을 지정함으로써 내부에서 생성하는 구조체의 타입을 전환하고 있습니다.
이렇게 호출자는 *female
타입인지, *male
타입인지 의식하지 않고, Title 메소드의 구현을 전환할 수 있습니다.
func NewPerson(gender Gender, firstName, lastName string) Person {
p := &person{firstName, lastName}
if gender == Female {
return &female{p}
}
return &male{p}
}
인터페이스의 동적 구현을 위한 임베딩 사용
구조체 타입에 임베딩할 수 있는 타입은 이름이 있는 타입이라면 어떤 타입이든 가능합니다.
그러나 타입 리터럴은 임베딩할 수 없습니다.
예를 들어, chan int
나 []int
와 같은 타입 리터럴은 구조체 타입에 임베딩할 수 없습니다.
반면에, fmt.Stringer
인터페이스와 같은 이름이 있는 인터페이스 타입은 구조체 타입에 임베딩할 수 있습니다.
type Foo struct {
chan int // 불가능
[]int // 불가능
fmt.Stringer // 가능
}
임베딩하는 타입이 구조체 타입이나 그 포인터일 경우, 해당 타입의 값만 임베딩할 수 있습니다.
그러나 임베딩하는 타입이 인터페이스 타입이라면, 그 인터페이스를 구현하는 어떤 값이든 임베딩할 수 있습니다.
type Foo struct {
*Bar // *Bar 타입의 값만 임베딩 가능
fmt.Stringer // fmt.Stringer 인터페이스를 구현하면 임베딩 가능
}
위에서 언급한 것처럼, 임베딩된 타입은 익명 필드로 선언되어 있기 때문에, 값을 동적으로 변경할 수 있습니다.
type Person struct {
fmt.Stringer
FirstName string
LastName string
Age int
}
func main() {
p := Person{
Stringer: nil,
FirstName: "Jisung",
LastName: "Park",
Age: 20,
}
fmt.Println(p.Stringer) // nil
p.Stringer = ??? // fmt.Stringer 인터페이스를 구현하면 할당 가능
}
위의 Person
타입에 fmt.Stringer
타입을 임베딩할 수 있지만, Person
타입의 값을 사용하여 String
메소드를 구현한 값이 아니라면 큰 의미가 없습니다.
이 경우에는 FirstName
이나 LastName
을 사용하여 문자열을 조합하는 것이 바람직합니다.
그래서 다음과 같이 fmt.Stringer
인터페이스를 구현하는 함수 타입을 생각해 봅시다.
StringerFunc
는 문자열을 반환하는 함수에 fmt.Stringer
인터페이스를 구현하기 위한 타입입니다.
type StringerFunc func() string
func (sf StringerFunc) String() string {
return sf()
}
더 나아가, Person
타입의 포인터를 인자로 받는 func(p *Person) string
타입의 함수를 StringerFunc
타입으로 캐스트 가능한 func() string
타입으로 변환하는 함수를 생각해 봅시다.
인자를 받지 않고 특정 변수(이 경우에는 p
)에 접근하기 위해서는 클로저를 사용하는 것이 좋습니다.
func BindStringer(p *Person, f func(p *Person) string) fmt.Stringer {
return StringerFunc(func() string {
return f(p)
})
}
BindStringer
함수의 반환 타입은 fmt.Stringer
타입이지만, 실제로 반환되는 값은 StringerFunc
타입입니다.
함수 내부에서는 첫 번째 인자인 p
와 두 번째 인자인 f
를 참조할 수 있는 func() string
타입의 클로저를 생성하고, StringerFunc
타입으로 캐스트하고 있습니다.
여기서 생성된 클로저 내부에서는 인자 p
와 f
를 참조할 수 있기 때문에, p
를 f
의 인자로 사용하여 함수 호출을 수행하고 있습니다.
이와 같이 func(p *Person) string
타입을 func() string
타입으로 변환하고, 더 나아가 StringerFunc
타입으로 캐스트함으로써, *Person
타입을 구현으로 사용한 fmt.Stringer
인터페이스를 만족하는 값을 생성할 수 있습니다.
그럼 이제 위의 함수를 *Person
타입을 초기화하는 NewPerson
함수에 통합해 봅시다.
그리고 fmt.Stringer
인터페이스의 구현을 동적으로 변경할 수 있도록 SetStringer
메소드를 준비해 봅시다.
func NewPerson(firstName, lastName string, age int) (p *Person) {
p = &Person{
Stringer: nil,
FirstName: firstName,
LastName: lastName,
Age: age,
}
p.Stringer = StringerFunc(func() string {
return fmt.Sprintf("%s %s (%d)", p.FirstName, p.LastName, p.Age)
})
return
}
func (p *Person) SetStringer(sf func(p *Person) string) {
p.Stringer = StringerFunc(func() string {
return sf(p)
})
}
NewPerson
함수에서는 fmt.Stringer
의 기본값으로, "Jisung Park (20)"과 같은 문자열을 반환하는 StringerFunc
을 임베딩하고 있습니다.
SetStringer
메소드는 *Person
타입을 인자로 받고, 문자열을 반환하는 함수를 인자 sf
로 받습니다.
sf
는 위에서 언급한 BindStringer
함수와 같은 방식으로 StringerFunc
로 캐스트되어, Person
타입의 익명 필드인 Stringer
에 설정됩니다.
SetStringer
를 사용하면, 임베딩된 fmt.Stringer
인터페이스의 구현을 실행 시에 동적으로 변경할 수 있습니다.
여기서 주의해야 할 점이 있습니다.
Person
타입에 임베딩된 fmt.Stringer
타입은 fmt
패키지에서 외부에 공개되어 있는 타입입니다.
따라서 Person
타입에 임베딩된 값을 Person
타입을 선언한 패키지 외부에서도 변경할 수 있습니다.
그래서 다음과 같이 Person
타입에 임베딩하는 타입은 fmt.Stringer
타입에 새로운 이름을 붙인 패키지 외부에 공개되지 않는 타입으로 만들어, 패키지 외부에서 임베딩된 값을 변경되지 않도록 할 수 있습니다.
그리고 stringer
인터페이스를 구현하는 타입은 fmt.Stringer
인터페이스도 구현하고 있기 때문에, 본질적인 동작은 변하지 않습니다.
// 패키지 외부에 공개되지 않는 타입으로 선언
type stringer fmt.Stringer
type Person struct {
stringer // 패키지 외부에서 할당할 수 없는 익명 필드
FirstName string
LastName string
Age int
}
마지막으로
이 글에서는 인터페이스에 관한 기초적인 지식을 다시 짚어보고, 더 나아가 응용적인 인터페이스 구현 방법을 설명했습니다.
저 자신도 여기서 언급한 응용적인 인터페이스 구현 방법이 현실의 Go 언어 프로그래밍에 어떻게 활용될 수 있는지 모색 중입니다.
그럼.
'Go' 카테고리의 다른 글
Go에서의 소수점 연산과 오차 처리 (0) | 2024.05.17 |
---|---|
json.Unmarshal 사용시 타임(time) 형식을 유연하게 변경하는 방법 (0) | 2024.05.04 |
Go의 채널 처리 패턴 모음 (0) | 2024.04.13 |
Go 언어로 간단한 수식 평가기 만들어보기 (0) | 2024.03.30 |
Go언어의 const 식별자 iota 소개 (1) | 2024.03.30 |