Golang/Basic

Golang 헷갈리는 문법 복습 - interface

시바도지 2022. 2. 19. 20:46
반응형

 

인터페이스가 어떤 용도로 사용되는지, 왜 이런 식으로 만들어 졌는지에 대한 이해가 선행되면 정의를 이해하는 게 훨씬 쉽다. 그래서 우선 인터페이스가 왜 쓰이는지에 대해 설명 후, 구체적인 것에 대해서는 이후에 설명하도록 하겠다.

 

1. Interface를 사용하는 이유

Go 언어는 타입에 민감하다. 함수의 경우 마찬가지다.

함수는 정해진 타입의 매개변수만 받고, 정해진 타입에 맞게 연산을 하고, 정해진 타입에 맞게 결과를 반환한다.

하지만 여러타입에 대해 적용할 수 있는 함수를 만들어야 할 때도 있다.

 

Go 언어에서 이러한 함수를 만들기 위해서는 각 타입별로 적용 가능한 함수들을 만들고, 이를 모두 합쳐서 하나의 큰 함수로 만드는 것이다. 하지만 여기서는 막히는 부분이 하나 있다.

Go언어는 매개변수를 받거나 결과를 반환할 때, 각각의 매개변수나 결과는 하나의 특정 타입으로 명시되어야만 한다. 그러면 어떻게 해야 하나의 특정 타입에 여러개의 타입이 들어가게 할 수 있을까?

이때 사용되는 것이 바로 interface다.

큰 함수에서 사용될 여러 타입들을 하나의 타입으로 포장해서 함수가 하나의 특정한 타입으로 받아들일 수 있게 해주는 것이 interface의 역할이다.

 

2. 예시

1. 빈 인터페이스

 

우선 빈 인터페이스에 대해서 예시를 들어보면

자신을 제곱한 결과를 반환하는 어떤 함수를 만들려고 하고, 이때 int와 float64 두 가지 타입을 인자로 받을 수 있도록 만들 계획이라고 한다.

이 함수를 만들려면 어떻게 하면 되겠는가?

int에 대한 함수와 float64에 대한 함수를 각각 만들고, 이를 하나의 함수로 합쳐주면 된다. 그리고 그 함수는 인터페이스를 인자로 받고 결과로 반환하도록 만들어주면 된다.

코드를 짜면 대충 이렇다. 다르게도 만들 순 있겠지만, 개념적으로는 이와 동일할 것이다.

package main

import (
	"fmt"
)

//인터페이스 정의
type allnum interface {

}

//개별 타입별 함수 정의
func IntSquare(num int) int {
	return num * num
}

func Float64Square(num float64) float64 {
	return num * num
}

//큰 함수 정의
func Square(num allnum) allnum {
	switch a := num.(type) {
		case int:
			return IntSquare(a)
	
		case float64:
			return Float64Square(a)
	}

	fmt.Println("int나 float64만 넣으세요")

	return nil
}

//예시
func main() {
	fmt.Println(Square(3))
	fmt.Println(Square(2.4))
}

여기서 우선 .(type)과 같은 형태의 구문이 무슨 뜻인지를 먼저 설명해야겠다.

이건 타입 단언(type assertion)이라는 건데, 간단히 말하면 interface라는 포장지를 찢고 원래 타입으로 복구해주라는 뜻이다.

 

예를 들어서 위의 Square라는 함수에 4라는 int값을 넣었다고 하자.

Square는 allnum이라는 인터페이스로 인자를 받기 때문에, 4라는 int값을 넣으면 num이라는 매개변수는 int 4가 되는 게 아니라, 그 값이 allnum이라는 인터페이스로 포장된 것을 값으로 갖는다.

이 상태로는 int타입만을 받는 IntSquare 함수에 넣을 수 없기 때문에, 인터페이스를 없애고 원래 타입으로 바꿔야 한다.

이때 num 뒤에 .(int)를 붙여줌으로써, 인터페이스를 없애고 원래의 int 타입으로 되돌릴 수 있다.

이렇게 원래의 타입으로 복구해주는 것이 타입 단언이고, 변수명.(원래 타입명) 의 식으로 시행된다.

 

 

 

이중에서도 .(type)은 type switch라는 특수한 경우에 사용되는 구문으로, switch 문에서만 시행할 수 있다.

인터페이스가 안에 품고 있는 타입별로 구문을 정의해 주기 위해 사용하는 것으로, 아래와 같이 사용할 수 있다.

switch 임의의변수명:=변수명.(type){

	case 가능한타입명1:
	
	구문1
	
	case 가능한타입명2:
	
	구문2
	
	...

}

 

이런 식으로 가능한 타입별로 구문을 설정해줄 수 있다.

다시 본론으로 돌아와서, 위의 Square함수를 살펴보자.

인터페이스로 숫자를 입력받은 뒤, type switch를 통해 원래 타입으로 타입 단언이 되어 a에 저장되고, a의 타입에 따라 구문이 실행되어 int 타입일 경우 IntSquare함수에, float64 타입일 경우 Float64Square함수에 인자로 들어간다.

결과는 IntSquare는 int, Float64Square는 float64를 반환하지만, Square의 최종 반환은 allnum이기 때문에 각 int/float64는 allnum 인터페이스에 포장되어 반환된다.

 

 

 

인터페이스의 핵심은 이게 끝이다.

여러 개의 타입들을 하나의 타입인 것처럼 묶어주기 위한 포장용 타입이고, 이 포장을 풀 때는 타입 단언(.(타입명))을 해 줘서 원래 타입으로 복구한다.

 

2. 메서드를 가진 인터페이스

 

그럼 이제는 위의 예제를 조금 변형해 보자.

IntSquare와 Float64Square를 각각 int와 float64의 Square라는 이름의 메서드로 만들고, allnum에 Square라는 이름의 메서드를 넣어줄 것이다.

다만 외부 패키지의 타입은 메서드를 지정해줄 수 없으므로, int와 float64를 각각 newint, newfloat64로 새로 정의해준 후 이에 대한 메서드를 추가하는 식으로 진행할 것이다.

package main

import (
	"fmt"
)

//인터페이스 정의
type allnum interface {
	Square() allnum
}

//새로운 타입 정의
type newint int
type newfloat64 float64

//타입 메서드 정의
func (n newint) Square() allnum {
	return n * n
}

func (n newfloat64) Square() allnum {
	return n * n
}

//예시
//allnum 인터페이스에서부터 각 타입(newint, newfloat64)의 Square() 메서드를 구현할 수 있다
func main() {
	var a allnum = newint(3)
	var b allnum = newfloat64(3.14)
	
	fmt.Println(a.Square())
	fmt.Println(b.Square())
}

 

(여기서 메서드를 정의할 때 newint, newfloat64를 반환하는 게 아니라 allnum을 반환한다는 걸 주의하자.

매개변수 및 반환값의 타입이 메서드끼리 동일해야 하기 때문에, 서로 다른 결과값 타입인 newint나 newfloat64를 allnum 인터페이스에 묶어 반환해주는 것이다.)

비록 메서드로 구현되어 있고, allnum도 비어있는 인터페이스가 아니라 Square라는 메서드를 가지고 있지만, 앞에서의 예제와 개념적으로는 동일하다.

 

newint 타입에 대한 메서드와 newfloat64 타입에 대한 메서드가 합쳐져, allnum이라는 인터페이스의 메서드로서 하나의 큰 함수가 만들어진 것이다.

차이가 있다면, 앞의 예제에서의 allnum 인터페이스는 모든 타입을 포함할 수 있었지만, 현 예제에서의 인터페이스는 allnum을 반환하는 Square() 메서드(Square() allnum)를 가진 타입, 즉 newint와 newfloat64 타입만을 포함한다는 것이다.

 

인터페이스는 인터페이스가 가진 메서드를 구현할 수 있는 타입만을 포함하기 때문이다.

그럼 여기서 의문이 하나 들 것이다.

왜 메서드가 기준이 되도록 만든 것일까?

그냥 인터페이스에 포함되는 타입을 직접 지정하도록 만들 수도 있었을 것이고(ex. interface{newint;newfloat64}와 같은 형식), 그렇게 만들었다면 인터페이스에 어떤 타입이 포함되는지 더 명확히 정의할 수도 있었을 것인데, 왜 메서드로 정의해서 메서드 구현만 되면 다 포함되도록 만든 것일까?

그 이유는 타입이 무엇인지를 기능적인 관점에서 바라보면 이해할 수 있다.

애초에 타입이란 도대체 무엇이며, 왜 타입을 분류해야 하는 것일까?

 

이에 대한 답으로 예를 들어 "어떤 값은 함수에 들어갈 수 있고, 어떤 값은 들어갈 수 없네? 그러면 이 함수를 사용하려면 여기에 들어갈 수 있는 값들과 없는 값들로 나눠야겠구나! 이 함수에 들어갈 수 있는 값들은 특정 타입이라고 정의하겠어!"와 같이 말한다면, 기능적인 관점에서 답을 낸 것이다.

 

다시 말해, 타입을 분류하는 이유는 특정 함수/연산/기타등등에 적용 가능한지를 알기 위해서이고, 따라서 타입의 정의는 함수에 들어갈 수 있는 값들의 집합으로 하겠다는 사고방식인 것이다.

이런 사고방식이 흔히 말하는 덕 타이핑이라고 보면 된다.

 

어떤 미리 정해진 타입을 이용해서 분류하는 게 아니라, 특정 기능을 수행할 수 있는 것들은 모두 특정 타입으로 분류하겠다는 것이다.

 

이를 바탕으로 위의 allnum 인터페이스를 다시 보면 어떻게 해석할 수 있을까?

바로 Square() allnum 이라는 특정 메서드에 적용될 수 있는 것들을 모두 지칭하는 타입이라고 볼 수 있다.

그리고 newint와 newfloat64는 Square() allnum 메서드에 적용될 수 있기 때문에 allnum이라는 타입에 속할 수 있는 것이다.

 

이제 개념적으로 이해할 만한 것은 다 설명이 된 것 같다.

이제 맨 처음에 얘기했던, "인터페이스가 method signature의 집합체"라던가, 메서드의 원형을 정의한다는 게 무슨 설명인지만 하면 될 것 같다.

메서드의 원형이라든지 method signature라든지 하는 것들은 모두 메서드의 기본 틀, 즉 메서드가 어떤 인자를 받으며 무엇을 반환하는지를 정의한 것을 말한 것이다.

위의 예제에서의 Square() allnum과 같은 것이다.

 

인터페이스가 메서드의 기본 틀을 정의한 것의 집합체라는 설명은, 각각의 메서드가 합쳐져 인터페이스의 메서드가 만들어진다(개별 메서드->인터페이스 메서드)고 보는 게 아니라, 순서를 반대로 해서 인터페이스가 메서드의 기본 틀을 정의하고 있으며, 개별 타입의 메서드들은 이 기본 틀을 따라 구체적으로 구현한 것(인터페이스 메서드->개별 메서드)이라는 관점에서 설명한 것이다.

물론 어떻게 이해하든 보는 관점만 다를 뿐, 사실상 같은 의미다.

이해하기 편한 대로 생각하면 된다.

요약하자면

  1. 인터페이스는 여러 타입에 적용되는 함수/연산/etc를 정의하기 위해, 여러 타입들을 포장하여 인터페이스라는 하나의 타입으로 만들어 준 것
  2. 인터페이스라는 포장을 찢기 위해선 타입 단언(.(타입명))을 해서 원래 타입으로 복구
  3. 메서드로 구분하는 이유는 기능적 관점으로 타입을 분류하겠다는 사고방식(덕 타이핑) 때문

 

 

 

출처 : https://gall.dcinside.com/mgallery/board/view/?id=gophers&no=325 

 

 

 

반응형