지난 글에서는 객체지향 프로그래밍을 하기 위한 기본적인 요소들에 대해 알아보았다. 이번 글에서는 이 요소들을 이용해 어떻게 객체지향 프로그래밍을 할 수 있는지 알아보겠다.
그 전에...
패키지 내의 함수 및 변수 호출은 인스턴스의 그것들을 호출하는 것과 유사하다. 그리고 이전 글에서 잠깐 언급했지만 Go언어의 접근제한은 패키지 단위로 이루어진다. 즉, Go언어에서의 패키지는 하나의 객체로 볼 수 있다. 그리고 캡슐화(은닉성)의 관점에서 패키지를 객체로 취급하는 것이 바람직하다 콘솔에서 패키지를 받아보자.
go get github.com/simp7/pracOOPFromGo
받은 파일은 이전 글의 객체들(Dog, Cat, Animal)을 패키지로 묶은 것이다. 이후 예제에서 사용될 예쩡이다.
Go언어에서의 상속성
Go언어에는 클래스 상속이 없다.
일반적인 객체지향 프로그래밍 언어에는 상속을 기본적으로 지원한다. 객체지향의 목표중 하나가 상속성인 만큼 이는 당연하다. 하지만 Go언어에는 클래스끼리의 정통적인 상속을 지원하지 않는다. 그렇다면 객체지향의 상속성에 위배되는게 아닌가? 여기서 객체 합성이 등장한다.
객체 합성
객체 합성은 객체 안에 객체를 넣는 것이다. 즉, 클래스의 변수로 상속할 클래스의 인스턴스를 받는다고 생각하면 된다. Go언어에서는 클래스(왠만한 클래스의 문법은 구조체에도 적용되므로 특별한 경우에만 구조체를 따로 언급하겠다) 내에 상속할 클래스 이름만 넣으면 자동으로 합성이 된다.
객체 합성은 정통적인 상속과 비슷하지만 크게 세가지 관점에서 다르다.
- 상속은 부모의 구현이 자식에게 노출되지만, 객체 합성은 객체의 구현이 서로 노출되지 않는다.
- 상속받은 객체는 부모 객체를 바꿀 수 없지만, 합성된 객체는 합성할 객체를 바꿀 수 있다.
- 상속은 보통 프로그래밍 언어에서 기본적으로 지원하지만 객체 합성은 지원하지 않는다.
첫번째 항목의 대표적인 예시가 C++의 protected이다. protected로 선언한 변수 및 메소드는 해당 클래스와 자식 클래스 끼리만 쓸 수 있다. 이는 객체지향의 성질 중 클래스 끼리의 내부 구현을 숨겨야 한다는 캡슐화(은닉성)에 위배된다. 객체 합성은 이런 문제로부터 자유롭다.
두번째 항목은 더 간단하다. 정통적인 상속은 상속할 객체가 하드코딩되어 있기 때문에 런타임에 상속할 객체를 변경할 수 없다. 하지만 객체의 합성은 내부 객체만 바꿔주면 되기 때문에 상속할 객체를 변경하기 쉽다.
Go언어의 객체 합성
Go언어는 객체를 아래와 같이 합성한다.
type A struct {
B //인터페이스, 구조체 모두 가능하다.
}
인터페이스를 합성하는 것은 이전 글의 인터페이스를 적용하는 것과 조금 다르다. 인터페이스를 적용하는 것은 정통적인 상속과 유사한데,
type C interface {
D //인터페이스만 가능하다
}
인터페이스는 내부 구현이 불가능하기 때문에 구조체를 합성할 수는 없다.
이제 예제를 보자.
package main
import (
"fmt"
"github.com/simp7/pracOOPFromGo" //go get으로 받은 패키지 호출
)
type Puppy struct {
model.Dog
shaggy bool
}
type Pet struct {
model.Animal //인터페이스도 합성할 수 있다.
owner string
}
func NewPuppy(d model.Dog) *Puppy {
p := new(Puppy)
p.Dog = d
p.shaggy = false
return p
}
func NewPet(animal model.Animal, owner string) *Pet {
p := new(Pet)
p.Animal = animal
p.owner = owner
return p
}
func (p *Pet) AnotherPet(another model.Animal) {
p.Animal = another //런타임에 내부 객체를 변경
}
func (p *Pet) Sounds() {
p.Animal.Sounds() //함수를 오버라이드할 때 내부 객체를 통해 상속받은 메소드 및 변수 호출
fmt.Printf("%s: How cute!\n", p.owner)
}
func main() {
var puppy *Puppy
var pet *Pet
d1 := model.NewDog("Baduk")
puppy = NewPuppy(d1)
puppy.Sounds() //따로 메소드나 변수가 정의되어 있지 않으면 부모 클래스에서 불러온다.
d2 := model.NewDog("Nureong")
puppy = NewPuppy(d2)
puppy.Sounds()
pet = NewPet(model.NewCat("Merry"), "Park")
pet.Sounds()
pet.AnotherPet(puppy) //puppy 역시 Animal이다.
pet.Sounds()
}
Baduk: woof woof!
Nureong: woof woof!
Merry: meow.
Park: How cute!
Nureong: woof woof!
Park: How cute!
Puddle은 pracOOPFromGo의 model 패키지 내에 있는 Dog 객체를 포함하고 있다. Pet은 model 패키지 내에 있는 Animal 객체를 포함하고 있다.
다음은 인터페이스의 합성이다.
package main
import (
"fmt"
model "github.com/simp7/pracOOPFromGo"
)
type Wild interface {
Appears()
}
type Bird interface {
model.Animal //인터페이스 상속, Sounds() 필요
Fly()
}
type WildBirds interface { //인터페이스 다중상속
Wild
Bird
} //Appears(), Sounds(), Fly()모두 존재하면 자동 적용
type Pigeon struct {
}
func (p *Pigeon) Appears() {
fmt.Println("Wild Pigeon appeared!")
}
func (p *Pigeon) Fly() {
fmt.Println("Pigeon flew away!")
}
func (p *Pigeon) Sounds() {
fmt.Println("coo!")
}
func NewPigeon() *Pigeon{
return new(Pigeon)
}
func main() {
var a WildBirds
a = NewPigeon()
a.Appears()
a.Sounds()
a.Fly()
}
Wild Pigeon appeared!
coo!
Pigeon flew away!
외전
좋은 관습
되도록 클래스 명과 클래스의 변수는 첫 글자를 소문자로 지어서 패키지 외부로부터의 접근을 차단하자. 인터페이스는 꼭 첫 글자를 대문자로 짓자. 즉 구현(클래스, 변수)은 은닉하고 표현(인터페이스)은 드러내는 것이다.
클래스의 메소드는 외부에서도 쓸 경우 인터페이스에도 정의하자. 내부에서만 쓸 경우 당연히 첫 글자를 소문자로 써서 접근을 제한하자.
위의 두 관습을 합치면 대략 이런 구조이다.
type A interface {
B()
}
type a struct {
x int
}
func (obj *a)B() {
}
func (obj *a)c() {
}
한 파일에 클래스들을 최소화하자. 되도록 한 파일당 클래스 하나씩 넣는 것이 좋다. 이 역시 Go언어의 접근제한을 이용하여 캡슐화 시키기 위함이다.
빈 인터페이스
말 그대로 비어있는 인터페이스이다. 필요로 하는 함수가 하나도 없는 인터페이스이다.
interface{}
모든 자료형 및 클래스는 이 인터페이스를 만족한다. 조금만 생각해보면 당연하다. 꼭 구현해야할 함수가 없기 때문이다. 반대로 말해서, 빈 인터페이스는 모든 자료형 및 클래스를 지칭할 수 있다. 대표적인 예로 fmt.Println이 있다.
빈 인터페이스로 매개변수를 받거나 내보내는 것은 쉽지만 단독으로 이용하기는 쉽지 않다. 자료형이 무엇인지도 모르는 변수를 처리하는 것은 불가능하기 때문이다.
여기서 타입 단언이 나온다. 타입 단언은 자료형이 interface{}일 때 제한적으로나마 해당 자료형처럼 사용할 수 있게끔 하는 것이다. 자세한 내용은 아래 예제를 통해 살펴보자.
package main
import (
"fmt"
)
func double(a interface{}) interface{} {
x, ok := a.(int)
if ok {
return 2*x
}
y, ok := a.(string)
if ok {
return fmt.Sprintf("%s%s",y,y)
}
z, ok := a.(bool)
if ok {
z = !z
return z
}
return nil
}
/*
func double(a interface{}) interface{} {
switch a.(type) {
case int:
return 2*a.(int)
case string:
return fmt.Sprintf("%s%s",a,a)
case bool:
return !a.(bool)
}
return nil
}
*/
func main() {
a := []interface{}{42,"Hello",false}
for _, v := range a {
fmt.Println(v)
fmt.Println(double(v))
}
}
42
84
Hello
HelloHello
false
true
Golang에서의 타입 단언은 두가지 방법이 있다.
변수로 직접 받을 경우 왼쪽 값은 결과값, 오른쪽 값은 해당 자료형이 맞는지에 대한 여부를 받는다. 보통 오른쪽 값이 true가 아닌 경우 왼쪽 값은 쓰레기 값이므로 위의 예제처럼 분기 처리를 한다.
주석처리된 부분처럼 switch-case문을 쓸 때에는 switch문에서 인터페이스의 타입을 직접 묻는다. 해당 타입에 맞는 case문을 찾아 처리한다.
마치며
이번 글에서는 Go언어를 이용해 객체지향 프로그래밍을 활용하는 방법에 대해 알아보았다. 사실 이렇게 한번 쭉 봤다고 객체지향을 자유자재로 쓰는 것도 아닐 뿐더러 곧 내용도 잊어버릴 것이다. 하지만 어느 순간에 Go언어를 이용하면서 객체지향 설계가 필요하다고 느낄 때, 이 글을 기억해놨다가 참고용으로 쓰이면 필자의 목표는 달성한 것이다.
참고 자료
MinJae Kwon, Go와 OOP, 2017.4.28
에릭 감마, 리처드 헬름, 랄프 존슨, 존 블리시디스, GoF의 디자인 패턴, 프로텍 미디어, 2015