출처: https://quii.gitbook.io/learn-go-with-tests/go-fundamentals/structs-methods-and-interfaces
- 개요
Go에서 관련있는 데이터를 연관시켜 표현할 수 있는 struct에 대하여 알아본다. structd의 method와 interface를 통한 다형성을 알아본다.
- struct example
사각형의 너비와 높이가 주어졌을 때 둘레를 구하는 코드를 작성해보자.
func TestPerimeter(t *testing.T) {
got := Perimeter(10.0, 10.0)
want := 40.0
if got != want {
t.Errorf("got %.2f want %.2f", got, want)
}
}
Perimeter를 구현하지 않았으므로 go test 를 수행하면 오류가 날 것이다.
func Perimeter(width float64, height float64) float64 {
return 0
}
compile만 되도록 위의 코드를 추가한 후 go test를 수행하면 test는 되지만 Fail이 날 것이다.
func Perimeter(width float64, height float64) float64 {
return 2 * (width + height)
}
위와 같이 작성하고 go test 를 수행하면 통과한다. 이로서 사각형의 둘레를 구하는 기능을 구현했다. 이제 너비를 구하는 기능을 작성해보자.
func TestPerimeter(t *testing.T) {
got := Perimeter(10.0, 10.0)
want := 40.0
if got != want {
t.Errorf("got %.2f want %.2f", got, want)
}
}
func TestArea(t *testing.T) {
got := Area(12.0, 6.0)
want := 72.0
if got != want {
t.Errorf("got %.2f want %.2f", got, want)
}
}
TestArea 함수를 만든다. 시간관계상 생략하지만 TDD cycle에 따라서 실패하는것도 확인하고 최종적으로 코드를 작성하도록 하자.
func Perimeter(width float64, height float64) float64 {
return 2 * (width + height)
}
func Area(width float64, height float64) float64 {
return width * height
}
test가 통과되도록 TDD cycle을 완료하면 위와 같은 코드가 완성될 것이다.
현재 코드는 한 가지 문제가 있는데 함수 이름만 보고 삼각형인 경우에도 너비를 구할 수 있다고 생각해서 Area 함수를 호출할 수 있다. 이를 방지하기 위해 이름을 RectangleArea 로 변경해도 되지만, Rectangle 이라는 새로운 자료형을 선언해서 캡슐화 하는 것이 더 깔끔한 해결책이 될 수 있다. 이럴때 struct를 사용한다.
type Rectangle struct {
Width float64
Height float64
}
위와 같이 Rectangle struct를 선언하고나서 test코드도 변경해주도록 하자.
func TestPerimeter(t *testing.T) {
rectangle := Rectangle{10.0, 10.0}
got := Perimeter(rectangle)
want := 40.0
if got != want {
t.Errorf("got %.2f want %.2f", got, want)
}
}
func TestArea(t *testing.T) {
rectangle := Rectangle{12.0, 6.0}
got := Area(rectangle)
want := 72.0
if got != want {
t.Errorf("got %.2f want %.2f", got, want)
}
}
struct의 field접근은 . 을 이용해서 접근한다. test가 통과하도록 구현함수도 변경해준다.
func Perimeter(rectangle Rectangle) float64 {
return 2 * (rectangle.Width + rectangle.Height)
}
func Area(rectangle Rectangle) float64 {
return rectangle.Width * rectangle.Height
}
- Method
이제 원에 대해서도 너비를 구하는 요구사항이 생겼다고 가정해보자. 원하는 기능을 표현하기 위해 Test 코드를 먼저 작성한다.
func TestArea(t *testing.T) {
t.Run("rectangles", func(t *testing.T) {
rectangle := Rectangle{12, 6}
got := Area(rectangle)
want := 72.0
if got != want {
t.Errorf("got %g want %g", got, want)
}
})
t.Run("circles", func(t *testing.T) {
circle := Circle{10}
got := Area(circle)
want := 314.1592653589793
if got != want {
t.Errorf("got %g want %g", got, want)
}
})
}
Circle에 대한 자료형이 없으므로 Circle의 struct를 선언해준다.
type Circle struct {
Radius float64
}
test를 다시 수행해보자. 인자 형이 맞지 않다고 나올것이다.
func Area(circle Circle) float64 { ... }
func Area(rectangle Rectangle) float64 { ... }
함수 오버로딩을 이용하여 해결해보려고 위와 같이 선언하면 Go에서는 오버로딩을 지원하지 않기 때문에 compile을 허용하지 않는다. 이때 선택할 수 있는 옵션은 2가지 인데 하나는 다른 패키지에 원의 너비를 구하는 함수를 선언하는것이다. 이 방법은 오버 엔지니어링이 될 가능성이 커서 제외한다. 다른 하나는 해당 타입의 메소드를 선언하는것이다.
여태까지 코드에서 함수만 정의 했지만 메소드는 이용했었다. t.Errorf 는 인스턴스 t의 Errorf 메소드를 사용했던것이다.
Go에서 메소드란 receiver와 함께 함수를 선언하는것이다. Java에서는 class파일에 메소드를 선언하지만 Go에서는 파일로 구분짓지 않는다. Go에서 메소드를 선언한다는것은 식별자와 메소드 이름을 메소드에 엮고, 그 메소드를 receiver형에 연결하는것이다.
메소드를 사용하기로 했으므로 struct에서 메소드를 호출하는 형태로 test 코드를 변환해준다.
func TestArea(t *testing.T) {
t.Run("rectangles", func(t *testing.T) {
rectangle := Rectangle{12, 6}
got := rectangle.Area()
want := 72.0
if got != want {
t.Errorf("got %g want %g", got, want)
}
})
t.Run("circles", func(t *testing.T) {
circle := Circle{10}
got := circle.Area()
want := 314.1592653589793
if got != want {
t.Errorf("got %g want %g", got, want)
}
})
}
우리는 현재 함수만 있고 메소드가 없어서 Area가 정의되지 않았다는 에러가 날것이다.
type Rectangle struct {
Width float64
Height float64
}
func (r Rectangle) Area() float64 {
return 0
}
type Circle struct {
Radius float64
}
func (c Circle) Area() float64 {
return 0
}
Go에서 메소드선언을 설명할 때 receiver가 등장해서 복잡한것처럼 설명이 되었지만 함수선언과 크게 다르지는 않다. 함수와 유일한 차이점은 func와 함수명 사이에 해당 메소드가 어떤 struct에 연결되는지 receiver만 써주면 된다.
receiver를 (c Circle) 과 같이 선언했다면 메소드내부에서 c로 sturct의 상태를 참조할 수 있다. Go에서 receiver형 참조변수는 관습적으로 첫글자만 사용한다.
test가 통과할 수 있도록 메소드 내부를 구현해준다.
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
func (c Circle) Area() float64 {
return math.Pi * c.Radius * c.Radius
}
- Interface
테스트코드에서 사각형이나 원의 실제 너비와 기대값을 비교하고 있다. 이런 로직을 추상화해서 중복을 없애려면 checkArea()와 같은 함수를 만들고, 사각형 혹은 원에 관계없이 인자로 넘겨서 너비를 구해서 비교할수도 있다.
Go에서도 인터페이스를 지원하는데 다른 언어에서 인터페이스의 역할이 그렇듯이 다형성을 지원하게 해주며 결합도를 줄여준다.
func TestArea(t *testing.T) {
checkArea := func(t testing.TB, shape Shape, want float64) {
t.Helper()
got := shape.Area()
if got != want {
t.Errorf("got %g want %g", got, want)
}
}
t.Run("rectangles", func(t *testing.T) {
rectangle := Rectangle{12, 6}
checkArea(t, rectangle, 72.0)
})
t.Run("circles", func(t *testing.T) {
circle := Circle{10}
checkArea(t, circle, 314.1592653589793)
})
}
여태까지 test코드를 리팩토링하면서 작성했던 helper 함수들과 비슷한 패턴이지만 Shape형을 인자로 받는다는점이 다르다. Shape인터페이스를 아래와 같이 정의해준다.
type Shape interface {
Area() float64
}
Rectangle, Circle과 같이 새로운 struct를 정의했을 때와 같이 type을 만들었다. 다만 struct가 아닌 interface라는점이 다르다. go test를 실행해보면 test가 성공할것이다.
나도 그랬듯이 Java만 하던 사람들은 여기서 이상한점을 느낀다. Java에서는 명시적으로 해당 interface를 구현한다는 implements같은 구문을 사용해야 한다. Go에서의 인터페이스는 암묵적이라고 할 수 있다. 함수의 인자가 interface이고 어떤 type을 넘겼을 때 해당 type이 interface와 매칭된다면 compile되는데 문제가 없다.
예제에서 Rectangle은 Area메소드를 가지며 float64형을 반환한다. Circle또한 동일하고 이는 interface에서 정의한 시그니처와 일치하므로 문제가 되지 않는것이다.
인터페이스를 이용하니 결합도가 낮아지는것을 확인할 수 있는데, helper메소드는 shape이 사각형인지 원인지 삼각형인지 전혀신경쓰지 않는다. 단지 너비를 구한다는 추상적인 기능에만 집중할뿐이다.
- Table driven tests
만약 여기서 삼각형의 너비도 구해야한다면 어떻게 될까? 삼각형에 대한 t.Run 메소드를 추가해야할까? 테이블 기반 테스트 기법을 이용하면 이런 문제점을 해결할 수 있다.
func TestArea(t *testing.T) {
areaTests := []struct {
shape Shape
want float64
}{
{Rectangle{12, 6}, 72.0},
{Circle{10}, 314.1592653589793},
{Triangle{12, 6}, 36.0},
}
for _, tt := range areaTests {
got := tt.shape.Area()
if got != tt.want {
t.Errorf("got %g want %g", got, tt.want)
}
}
}
위의 코드는 익명 struct의 배열을 선언하고 사각형, 원, 삼각형에 대한 test case들을 할당하였다. 이 코드에서 중요한건 익명 struct문법이 아니라 다른 도형들에 대한 요구사항이 추가 되더라도 새로운 test함수를 만들지 않고도 단순히 데이터 추가 만으로 test case를 추가할 수 있게되었다는 점이다.
지금도 꽤 괜찮은 코드이지만 test case들을 살펴보면 struct와 숫자만 있어서 이게 무엇을 의미하는지가 명확하지 않다. GoLand와 같은 편집기를 이용하면 속성을 자동으로 표시해주지만 이용하지 못한다면 아래와 같이 이름을 명시적으로 부여해주는게 더 의도를 명확히 나타내준다고 할 수 있다.
{shape: Rectangle{Width: 12, Height: 6}, want: 72.0},
{shape: Circle{Radius: 10}, want: 314.1592653589793},
{shape: Triangle{Base: 12, Height: 6}, want: 36.0},
Test수행 후 출력된 부분도 손봐주면 더 좋은 코드를 작성할 수 있다. 위에서는 실패하면 단순히 기대값과 예상값이 일치하지 않는다는 메시지만 뿌려준다. 이때 코드를 아래와 같이 작성하면 실패한 case에 대해 더 많은 정보를 볼 수 있다.
func TestArea(t *testing.T) {
areaTests := []struct {
name string
shape Shape
hasArea float64
}{
{name: "Rectangle", shape: Rectangle{Width: 12, Height: 6}, hasArea: 72.0},
{name: "Circle", shape: Circle{Radius: 10}, hasArea: 314.1592653589793},
{name: "Triangle", shape: Triangle{Base: 12, Height: 6}, hasArea: 36.0},
}
for _, tt := range areaTests {
// using tt.name from the case to use it as the `t.Run` test name
t.Run(tt.name, func(t *testing.T) {
got := tt.shape.Area()
if got != tt.hasArea {
t.Errorf("%#v got %g want %g", tt.shape, got, tt.hasArea)
}
})
}
}
'Language > Go' 카테고리의 다른 글
Go - maps (0) | 2021.12.31 |
---|---|
Go - pointers & errors (0) | 2021.12.26 |
Go - array, slice, test coverage (0) | 2021.12.04 |
Go - Iteration & Benchmark (0) | 2021.12.04 |
Go - Go with TDD (0) | 2021.12.04 |
댓글