Go 언어 - OOP 흉내 내기

검프·2021년 4월 17일
5

Go 언어 학습

목록 보기
6/9
post-thumbnail

The Ultimate Go Study Guide의 내용을 참고하여 작성했습니다.

Go 언어는 Class상속Inheritance^{Inheritance}을 지원하지 않습니다. 대신 구조체 합성Struct composition^{Struct\ composition}을 통해서 객체지향 프로그래밍을 흉내 낼 수 있습니다. 어느 정도 객체지향 프로그래밍의 장점을 사용할 수는 있지만 객체지향 프로그래밍을 위한 풍부한 언어적 지원을 한다고 보기는 어렵기 때문에 흉내 내기라고 표현해 봤습니다.

  • 클래스를 구조체로 구현
  • 함수로 생성자Constructor^{Constructor} 구현
  • 함수를 구조체 리시버Receiver^{Receiver로} 연결하여 메서드Method^{Method}를 구현
  • 상속 대신 임베딩Embedding^{Embedding}으로 구조체를 재사용
  • 인터페이스로 다형성Polymorphism^{Polymorphism}을 구현

도형의 넓이를 계산하는 간단한 예제로 살펴보겠습니다. 우선 인터페이스를 하나 정의합니다. Area는 면적을 계산하는 인터페이스입니다. Go 언어에서는 Java 같은 언어의 implements 키워드가 존재하지 않습니다. 즉, 인터페이스를 명시적으로 따르기 위한 문법이 존재하지 않습니다. 컴파일 시점에 특정 타입 T의 메서드 집합에 인터페이스에서 정의한 함수 F(), F1().. 모두를 구현하고 있으면 암시적으로 인터페이스를 충족하는 것으로 결정됩니다.

package main

type Area interface {
	Area() float64
}

shapes 패키지에는 여러 도형을 관리하는 패키지입니다.shape 구조체는 도형을 표현하는 구조체로 다른 도형들의 부모가 되는 추상(?) 클래스Abstract Class^{Abstract\ Class} 역할을 합니다. <1> 에서 shape 구조체와 멤버 필드는 모두 패키지 내부에서만 접근 가능하도록 선언했습니다. 이렇게 하면 패키지 외부에서 shape 구조체를 생성할 수 없게됩니다.

<2> 에서 Area 인터페이스를 구현합니다. 리시버를 이용하여 shape 구조체의 메서드로 선언합니다. shape 구조체를 추상(?) 클래스라고 설명했지만 Go 언어에서는 모든 메서드를 구현해야만 하며 추상 클래스를 표현할 방법이 없습니다. 여기서는 넓이를 0으로 리턴합니다.

<3> 에서 fmt.Stringer 인터페이스를 구현합니다. 도형 객체의 타입, 이름, 넓이를 출력합니다.

package shapes

import "fmt"

// <1>
type shape struct {
	name string
}

// <2>
func (*shape) Area() float64 {
	return 0
}

// <3>
func (s *shape) String() string {
	return fmt.Sprintf("Type=[%T], Name=[%s], Area=[%f]", s, s.name, s.Area())
}

Rectangle 구조체는 사각형을 표현합니다. <1> 에서 shape 구조체를 임베딩합니다. shape 구조체는 내부 타입Inner type^{Inner\ type}이 되고 Rectangle 구조체는 외부 타입Outer type^{Outer\ type}이 됩니다. 구조체를 임베딩하면 내부 타입의 메서드는 외부 타입의 메서드로 호출할 수 있게 됩니다. 이를 내부 타입 승격Inner type promotion^{Inner\ type\ promotion}이라고 합니다. 내부 타입 승격 시 메서드의 수신자는 외부 타입이 아닌 내부 타입이 됩니다.

<2> 에서 넓이와 높이 필드를 패키지 내부에서만 접근할 수 있도록 선언합니다. <3> 에서는 생성자 함수를 정의합니다. 생성자 함수는 New구조체명 네이밍 관례를 많이 사용합니다. 생성자 함수에서는 Rectangle 객체를 생성하여 참조를 반환합니다. 구조체의 멤버 필드인 w, h는 생성자 함수를 통해서만 초기화할 수 있게됩니다. 필요 시 Getter, Setter를 추가하여 캡슐화Encapsulation^{Encapsulation}의 장점을 누릴 수 있습니다. 하지만 같은 패키지 내부에서는 Getter, Setter를 통하지 않고 접근이 가능하므로 엄격한 의미에서 캡슐화되었다고 보기는 어렵습니다.

<4> Area 인터페이스를 구현합니다. 도형이 면적을 계산하는 예제이므로 Area() 함수가 핵심 비즈니스 로직에 해당하며 각 도형의 면적을 계산하여 반환합니다. Rectangle 구조체의 Area() 메서드를 호출하면 shapeArea() 메서드가 아닌 RectangleArea() 메서드가 호출 됩니다. 여기서 착각하지 말아야 할 것은 이것이 메서드 오버라이딩Method overriding^{Method\ overriding}은 아니라는 점입니다. 단순히 Rectangle 구조체도 독립적으로 Area 인터페이스를 구현하고 있을 뿐입니다.

package shapes

import "fmt"

type Rectangle struct {
	// <1>
	shape

	// <2>
	w float64
	h float64
}

// <3>
func NewRectangle(w, h float64) *Rectangle {
	return &Rectangle{shape{"사각형"}, w, h}
}

// <4>
func (s *Rectangle) Area() float64 {
	return s.w * s.h
}

func (s *Rectangle) String() string {
	return fmt.Sprintf("Type=[%T], Name=[%s], Area=[%f]", s, s.name, s.Area())
}

Triangle은 삼각형, Circle은 원을 표현하는 구조체입니다.

package shapes

import "fmt"

type Triangle struct {
	shape

	w float64
	h float64
}

func NewTriangle(w, h float64) *Triangle {
	return &Triangle{shape{"삼각형"}, w, h}
}

func (s *Triangle) Area() float64 {
	return s.w * s.h / 2
}

func (s *Triangle) String() string {
	return fmt.Sprintf("Type=[%T], Name=[%s], Area=[%f]", s, s.name, s.Area())
}
package shapes

import "fmt"

const pi = 3.141592653589793238462643383279502884197169399375105820974944

type Circle struct {
	shape

	r float64 // 원의 반지름
}

func NewCircle(r float64) *Circle {
	return &Circle{shape{"원"}, r}
}

func (s *Circle) Area() float64 {
	return s.r * s.r * pi
}

func (s *Circle) String() string {
	return fmt.Sprintf("Type=[%T], Name=[%s], Area=[%f]", s, s.name, s.Area())
}

내부 타입 승격을 확인해보기 위한 목적으로 아무것도 없는 도형인 Empty 구조체를 정의합니다. Empty 구조체는 shape 구조체를 임베딩할 뿐 AreaStringer 인터페이스를 구현하지 않았습니다. 이렇게 하면 Empty 객체에 Area(), String() 메서드를 호출하면 내부 타입 승격이 이루어지면서 내부 타입인 shape 구조체의 메서드가 호출됩니다. 당연히 메서드의 리시버도 shape 객체가 됩니다.

package shapes

type Empty struct {
	shape
}

func NewEmpty() *Empty {
	return &Empty{shape{"도형"}}
}

이제 도형 객체들을 이용해서 Go 언어가 제공하는 다형성과 내부 타입 승격을 확인해 보겠습니다. <1> 에서 다양한 도형들의 객체를 생성합니다. 생성한 객체는 Area 인터페이스 타입의 슬라이스인 someShapes 변수에 할당합니다. 인터페이스는 타입이므로 인터페이스를 만족하는 다른 타입을 위한 다형성 타입으로 사용이 가능합니다.

<2> 에서는 someShapes 슬라이스를 순회하면서 각 도형의 String(), Area() 메서드를 호출합니다. 결과를 확인해보면 사각형, 삼각형, 원은 각 외부 타입에서 정의한 메서드가 호출된 것이 확인됩니다.

주목한 부분은 <3> 인데요, Empty 구조체는 Area, Stringer 인터페이스를 구현하지 않았습니다. 이로 인해 Empty 객체에 대한 Area(), String() 메서드 호출은 내부 타입 승격이 이루어져 shape 구조체의 메서드가 호출됩니다.

package main

import (
	"fmt"
	"hello-go/src/shapes"
)

func main() {
	// <1>
	var someShapes = []Area{
		shapes.NewRectangle(4, 5),
		shapes.NewTriangle(4, 5),
		shapes.NewCircle(5),
		shapes.NewEmpty(),
	}

	// <2>
	for _, s := range someShapes {
		fmt.Println(s, s.Area())
	}
}

<출력>
Type=[*shapes.Rectangle], Name=[사각형], Area=[20.000000] 20
Type=[*shapes.Triangle], Name=[삼각형], Area=[10.000000] 10
Type=[*shapes.Circle], Name=[], Area=[78.539816] 78.53981633974483
Type=[*shapes.shape], Name=[도형], Area=[0.000000] 0 // <3>

착각하지 말아야 할 것은 임베딩을 통한 구현은 HAS-A 관계이며 IS-A 관계가 아니라는 점입니다. 즉 아래와 같이 사용 하는 것은 불가능 합니다.

var rect shape = NewRectangle(4, 5)

간단하게 Go 언어에서 객체지향 프로그래밍을 지원하는 기능을 살펴봤습니다. 엄밀한 의미에서 Go 언어는 객체지향 언어가 아닙니다. 객체지향 방법론을 이용하여 프로그래밍하기를 원한다면 Go 언어가 아닌 다른 언어를 선택하는게 바람직할 것 같습니다.

profile
권구혁

0개의 댓글