The Ultimate Go Study Guide의 내용을 참고하여 작성했습니다.
Go 언어에는 서브타이핑이라는 개념이 없습니다. 모든 타입은 고유하며 OOP 언어에서 이야기하는 부모 타입, 자식 타입 같은 IS-A 관계가 존재하지 않습니다. 즉, 상속을 통해서 타입 계층을 구현하는 것이 불가능합니다.
아래 예제는 타입 계층을 구현하는 시도입니다. 동작하지도 않을뿐더러 좋은 Go 언어 프로그래밍 설계 원칙도 아닙니다.
<1>
에서 Animal
은 동물을 추상화하여 기본적인 속성을 정의합니다. <2>
에서 Speak()
메서드는 동물들이 말하는 방식을 일반화하는 행동을 정의하고 있습니다. 말하지 못하는 동물이 있기 때문에 모든 동물의 특성이 아닐뿐만 아니라 Animal
은 구체적인 동물을 나타내지 않으므로 무의미한 메서드일 수 있습니다.
<3>
에서 Dog
과 Cat
은 Animal
의 모든 속성과 Dog
과 Cat
의 고유한 속성을 정의합니다. 그리고 <4>
에서 Dog
과 Cat
의 말하는 방식을 정의합니다.
// <1> type Animal struct { Name string IsMammal bool } // <2> func (a *Animal) Speak() { fmt.Println("UGH!", "My name is", a.Name, ", it is", a.IsMammal, "I am a mammal") } // <3> type Dog struct { Animal PackFactor int } // <4> func (d *Dog) Speak() { fmt.Println("Woof!", "My name is", d.Name, ", it is", d.IsMammal, "I am a mammal with a pack factor of", d.PackFactor) } // <3> type Cat struct { Animal ClimbFactor int } // <4> func (c *Cat) Speak() { fmt.Println("Meow!", "My name is", c.Name, ", it is", c.IsMammal, "I am a mammal with a climb factor of", c.ClimbFactor) }
이제 위 설계를 바탕으로 사용자 코드를 작성해 보겠습니다.
func main() { // <1> animals := []Animal{ // Cannot use 'Dog{ Animal: Animal{ Name: "Fido", IsMammal: true, }, PackFactor: 5, }' (type Dog) as type Animal Dog{ Animal: Animal{ Name: "Fido", IsMammal: true, }, PackFactor: 5, }, // Cannot use 'Cat{ Animal: Animal{ Name: "Milo", IsMammal: true, }, ClimbFactor: 4, }' (type Cat) as type Animal Cat{ Animal: Animal{ Name: "Milo", IsMammal: true, }, ClimbFactor: 4, }, } for _, animal := range animals { animal.Speak() } }
미리 이야기했듯이 위 코드는 컴파일되지 않습니다. <1>
에서 Animal
타입을 이용해서 Dog
과 Cat
을 그룹핑했기 때문인데요, 이것이 정상적으로 동작하려면 서브타이핑을 지원해야만 합니다. Go 언어에서는 서브타이핑에 의한 그룹핑을 권장하지 않습니다. 위 코드는 아래와 같은 문제가 있습니다.
Animal
타입의 객체를 만들거나 단독으로 사용할 필요가 없음에도 구현을 제공합니다.Animal
타입은 개념적으로 추상 타입에 가깝지만 Speak()
메서드 구현은 일반화이며 예제에서는 사용하지도 않습니다.Go 언어에서는 공통의 상태가 아닌 인터페이스를 이용한 공통의 행동으로 그룹화하는 설계를 지향합니다. Go 언어에서는 인터페이스를 명시적으로 구현할 필요가 없습니다. 컴파일 시점에 인터페이스 충족 여부를 판단하는 암시적인 방법을 사용합니다. 서브타이핑 예제를 인터페이스를 이용한 그룹핑으로 개선해 보겠습니다.
<1>
에서 Speaker
인터페이스를 정의합니다.Speaker
인터페이스는 구체적 타입이 따라야하는 계약입니다. 위 예제와 달리 Animal
타입은 제거하며 Dog, Cat
은 Speak()
메서드를 구현하여 Speaker
그룹이 됩니다.
// <1> type Speaker interface { Speak() } type Dog struct { Name string IsMammal bool PackFactor int } func (d Dog) Speak() { fmt.Println("Woof!", "My name is", d.Name, ", it is", d.IsMammal, "I am a mammal with a pack factor of", d.PackFactor) } type Cat struct { Name string IsMammal bool ClimbFactor int } func (c Cat) Speak() { fmt.Println("Meow!", "My name is", c.Name, ", it is", c.IsMammal, "I am a mammal with a climb factor of", c.ClimbFactor) }
이제 Speaker
인터페이스를 이용하여 Dog, Cat
을 그룹핑합니다. Go 언어에서 타입을 그룹핑하는 방법으로 인터페이스를 이용한 방법을 추천합니다.
func main() { speakers := []Speaker{ Dog{ Name: "Fido", IsMammal: true, PackFactor: 5, }, Cat{ Name: "Milo", IsMammal: true, ClimbFactor: 4, }, } for _, spkr := range speakers { spkr.Speak() } } <출력> Woof! My name is Fido , it is true I am a mammal with a pack factor of 5 Meow! My name is Milo , it is true I am a mammal with a climb factor of 4
Go 언어에서는 암시적 인터페이스를지원합니다. 암시적 인터페이스란 구현해야 할 인터페이스를 정의할 필요 없이 인터페이스를 충족시키는 메서드를 정의하면 되는 특징을 이야기합니다. Go 언어 설계자들은 정적 타입 언어에 동적 타입 언어 스타일의 코딩을 접목시키고자 했습니다. 이를 가능하게 해주는 것이 암시적 인터페이스. 즉, 덕 타이핑 방식으로 동작하는 인터페이스 지원입니다. 이를 통해서 미묘하게나마 남아있는 인터페이스와 구조체간의 결합도를 없애고 더 유연하게 개발하는 것이 가능하도록 했습니다.
물론 인터페이스가 명시적이지 않기 때문에 생기는 불편함으로 인한 논란도 있습니다. 모든 것은 트레이드 오프가 있기 마련입니다.
속시원한 답을 찾지는 못했습니다. 하지만 몇몇 자료에서 Go 언어 설계자들이 서브타이핑을 통한 타입 계층화 과정에서 Solid 원칙 중 리스코프 치환 원칙이 위배되는 문제를 언어적으로 방지하기 위해서 서브타이핑을 지원하지 않았을 것으로 추측하는 내용을 찾을 수 있었습니다.
리스코프 치환 원칙은 계약 관계를 준수하는 코드 작성과 관련된 원칙입니다. 이 원칙을 준수하는 코드는 예상할 수 없는 동작을 방지할 수 있게됩니다.
타입 S가 타입 T의 하위 타입이라면 프로그램에서 타입 T의 객체를 타입 S의 객체로 치활할 수 있어야 한다.
<바바로 리스코프>서브타입은 언제나 자신의 기반 타입으로 교체할 수 있어야 한다.
<로버트 C 마틴>
리스코프 치환 원칙 위배를 설명할때 자주 등장하는 직사각형을 상속받은 정사각형 예제입니다. Go에서는 상속을 지원하지 않으므로 Java 예제로 설명합니다.
먼저 <1>
에서 Area
인터페이스를 정의합니다. 이 인터페이스는 도형의 면적을 계산하여 반환하는 행위를 정의하는 일종의 계약입니다. 인터페이스를 구현하는 클래스들은 이 것을 충실히 지켜야합니다.
<2>
에서 직사각형 클래스 Rectangle
과 속성으로 넓이, 높이를 정의합니다. 그리고 Area
인터페이스를 구현하는 것으로 직사각형의 면적을 반환하는 계약을 맺습니다.
<3>
에서는 정사각형 클래스 Square
를 정의하면서 Rectangle
클래스를 상속했습니다. 정사각형이기 때문에 넓이와 높이를 같게 유지하기 위해서 setWidth(), setHeight()
메서드를 재정의 했습니다.
public class LSPViolation { // <1> static interface Area { public int getArea(); } // <2> static class Rectangle { protected int width; protected int height; public int getWidth() { return width; } public void setWidth(int width) { this.width = width; } public int getHeight() { return height; } public void setHeight(int height) { this.height = height; } public int getArea() { return width * height; } } // <3> static class Square extends Rectangle { @Override public void setWidth(int width) { this.height = width; this.width = width; } @Override public void setHeight(int height) { this.height = height; this.width = height; } } // <4> public static void main(String[] args) { Rectangle rect = new Rectangle(); rect.setWidth(10); rect.setHeight(20); System.out.printf("Area=[%d], Assert=[%b]\n", rect.getArea(), equalsInt(rect.getArea(), 200)); rect = new Square(); rect.setWidth(10); rect.setHeight(20); System.out.printf("Area=[%d], Assert=[%b]\n", rect.getArea(), equalsInt(rect.getArea(), 200)); } public static boolean equalsInt(int v1, int v2) { return v1 == v2; } } <출력> Area=[200], Assert=[true] Area=[400], Assert=[false]
이제 <4>
에서 직사각형과 정사각형의 넓이를 계산해서 출력합니다. 결과를 확인해보면 정사각형의 넓이가 기대결과와 다르게 계산되는 것을 확인할 수 있습니다. 직사각형 클래스를 상속한 정사각형 클래스는 getArea()
에 대해서 정상적으로 동작하는 것을 보장해야하지만 getWidth(), getHeight()
를 재정의하는 과정에서 이를 지키지 못하게됩니다.
이것이 상속을 사용했을 경우 발생 가능한 리스코프 치환 법칙 위배 상황입니다. 서브 타입인 Square
는 Rectangle
이 맺은 Area
계약을 충실히 이행해야하지만 그러지 못하고 있습니다.
물론 Square
에서 getArea()
를 재정의하면 되지 않겠냐고 반문할 수 있습니다. 여기서 중요한 것은 복잡한 부모 클래스와 사용 컨텍스트에서 문제가 생기지 않도록 상속하고 LSP를 준수하는 것은 대단히 어렵다는 점입니다.
Go 언어는 서브타이핑을 지원하지 않기 때문에 그렇지 않은 언어에 비해서 이런 문제의 발생 가능성이 낮습니다. 하지만 컴포지션과 인터페이스를 사용하는 상황에서 발생 가능한 문제입니다. 아마도 Go 언어 설계자들은 서브클래싱을 잘못 사용하여 발생하는 리스코프 치환 원칙 위배 문제를 잘 알고 있었고, 이 때문에 서브클래싱을 지원하지 않았을 것으로 생각합니다.