개요
Go언어는 클래스개념이 존재하지 않는다. 대신 구조체를 클래스 같이 사용하고, 메소드를 구조체 밖에 선언하는 형태로 사용한다.이 외 특징으로는 OOP특성중 하나인 상속성(inheritance)를 지원하지 않고, 인터페이스(interface)를 지원한다. 우선 이 포스트에서는 구조체, 메소드에 대해서 알아보자
구조체
구조체 기본
메소드를 알기 전에 구조체를 알아야 한다. C언어를 했다면 구조체를 접했을 것이다. 구조체는 여러 필드를 묶어서 작성한다. 배열이 같은 타입 값들을 변수 하나로 묶어주었다면, 구조체는 여러 타입의 값들을 변수 하나로 묶어주는 기능을 한다. 기본적인 선언은 아래와 같다.
type (type name) struct{
(field name) (field value type)
}
예시 구조체 하나를 작성해 보자.
type Student struct {
Name string
Class int
No int
Score float64
}
구조체 타입 변수를 선언하는것은 일반적인 원시타입 변수 선언과 다를게 없다.
var student1 Student
위와같이 초기화를 하면 모든 필드가 각 타입의 기본 필드로 초기화된다. 모든 필드를 초기화 하고 싶은 경우 중괄호 사이에 넣어서 초기화 하면 된다. 필드 순서대로 입력시, 각 필드에 초기화가 되고 일부 필드만 초기화 하고 싶다면 '필드명 : 필드값' 형태로 초기화 해주면 된다.
// 모든 필드를 필드 순서대로 입력하여 초기화
student := Student{"hoplin", 1, 1, 100.0}
//일부 필드만 초기화하기
var student2 Student = Student{Name: "hoplin", Class: 1}
구조체 필드 접근
배열에서는 각 원소에 접근하기 위해서 '인덱싱' 이라는 것을 하여 접근했다. 구조체는 원소가 아닌 '필드' 가 있다. 이 필드에 접근하기 위해서는 '.' 연산자를 이용해서 접근할 수 있다. 필드 접근은 아래 형태로 접근을 한다.
(구조체 명).(필드)
student := Student{"hoplin", 1, 1, 100.0}
var student2 Student = Student{Name: "hoplin2", Class: 2}
fmt.Println(student.Name)
fmt.Println(student2.Name)
중첩 구조체
구조체는 다른 구조체를 필드 타입으로 가질 수 있다. 중첩 구조체는 두가지의 형태가 있다.
- 내장 타입 방식
- 포함된 필드 방식
< 내장 타입 방식 >
우선 내장 타입 방식은 일반적인 구조체 선언 방식과 동일하다. 구조체 필드에 다른 구조체 타입의 필드를 선언하면 된다.
package main
import "fmt"
type User struct {
Name string
ID string
Age int
}
type VIPUser struct {
UserInfo User // 내장 타입 방식
VIPLevel int
Price int
}
func main() {
user1 := User{"Hoplin", "hoplin1234", 24}
user1_vip := VIPUser{user1, 1, 10000}
user2_vip := VIPUser{
User{"testname", "abc1234", 24},
1,
10000,
}
fmt.Println(user1_vip.UserInfo.ID)
fmt.Println(user2_vip.UserInfo.Age)
}
< 포함된 필드 방식 >
구조체에서 다른 구조체를 포함할 때 필드 명을 생략하면 '.' 을 이용해 한번에 접근이 가능하다. 이에 대한 예제를 간단히 작성해 보자.
package main
import "fmt"
type User struct {
Name string
ID string
Age int
}
type VIPUser struct {
User // 포함된 필드 방식
VIPLevel int
Price int
}
func main() {
user1 := User{"Hoplin", "hoplin1234", 24}
user1_vip := VIPUser{user1, 1, 10000}
user2_vip := VIPUser{
User{"testname", "abc1234", 24},
1,
10000,
}
fmt.Println(user1_vip.ID)
fmt.Println(user2_vip.)
}
얼핏 보면 달라진것 없어보이지만 VIPUser구조체를 보면 구조체 타입만 선언하고 구조체 필드명은 생략한것을 볼 수 있다. 그렇다면 이 두 방식의 차이점은 무엇일까?
위의 사진을 보자. 왼쪽같은 경우에는 내장타입 필드 방식이고 오른쪽같은 경우 포함된 필드 방식이다. 내장타입 필드 방식에서는 중첩 구조체의 필드에 접근하기 위해 (구조체).(중첩 구조체).(중첩 구조체 필드명) 과 같이 접근한 반면, 포함된 필드 방식에서는 (구조체).(중첩구조체 필드명) 과 같이 바로 접근하는것을 볼 수 있다. 두 사진을 비교해보면 구조체가 접근할 수 있는 필드에 차이가 있는것을 볼 수 있다.
포함된 필드 방식이 간결하고 좋아보이지만, 문제점이 하나 있다. 바로 필드가 중복되는 경우이다. 아래 예제코드를 살펴보자.
type User struct {
Name string
ID string
Age int
Level int // 중복필드
}
type VIPUser struct {
User
Level int // 중복필드
Price int
}
User구조체에도 Level필드가 있고, User구조체를 중첩하고 있는 VIPUser에도 Level필드가 있다. 그리고 이는 포함된 필드 방식으로 작성된 구조체이므로 VIPUser.Level처럼 접근한 경우 어떤 Level에 접근했는지 알 수 없다. 이런 경우에는 겹치는 필드가 어느 타입에 해당하는 구조체 필드인지 명시해 주어야 한다.
package main
import "fmt"
type User struct {
Name string
ID string
Age int
Level int
}
type VIPUser struct {
User
Level int
Price int
}
func main() {
user1 := User{"Hoplin", "hoplin1234", 24, 30}
user1_vip := VIPUser{user1, 1, 10000}
fmt.Println(user1_vip.Level) // VIPUser구조체의 Level필드
fmt.Println(user1_vip.User.Level) // VIPUser구조체의 중첩된 User타입의 Level필드
}
메소드
메소드란?
메소드란 함수의 일종이다. 위에서도 말했듯이 GO언어는 클래스 개념이 없다. 대신 구조체를 클래스처럼 사용하고, 구조체 밖에 메소드를 지정한다. 구조체 밖에 메소드를 정의할때는 '리시버' 라는 기능을 사용하여 정의한다.
통상적인 OOP에서는 '클래스는 메소드를 멤버로 속성을 표현하는 필드(data)와 기능을 표현하는 메소드(function)를 가진다. 이중 메소드는 특정 작업을 수행하기 위한 명령문의 집합이다' 라고 정의한다.
리시버란?
리시버란 메소드가 어느 구조체에 속하는지 표시할 방법이 필요할때 사용하는 방법이다. 쉽게말해 리시버는 메소드가 속하는 타입을 알려주는 기법이다.
왜 사용할까?
메소드를 통해 데이터 기능을 묶어 응집도를 높이며, 코드 재사용성, 모듈화를 통한 가독성 증진을 할 수 있다.
별칭 타입
메소드로 들어가기전에 별칭타입에 대해 알아보자. 별칭타입은 타입에 대해 별칭을 지정하는 것이다. 이를 Go언어에서는 타입정의라고 한다. 기존 타입에 대해서도 별칭을 지정해줄 수 있고, 사용자가 정의한 구조체 타입으로 별칭을 지을 수 도 있다.
package main
import "fmt"
// int타입 별칭
type myInt int
// 구조체
type test struct {
Name string
age int
}
// 사용자 정의 구조체 타입 별칭
type myStruct test
func main() {
var a myInt = 20
var b int = 10
// fmt.Println(a + b) // 에러 : go언어에서는 다른 타입간의 연산이 안된다. myInt가 int의 별칭이긴 하지만 별칭타입이므로 엄연히 다른 타입으로 인식한다
fmt.Println(int(a) + b)
var e myStruct = myStruct{
"Hoplin",
20,
}
fmt.Println(e.Name)
}
메소드 선언하기
메소드를 선언하기 위해서 리시버를 사용해야한다. 리시버는 func키워드와 함수 이름 사이에 명시한다.
type testStruct struct{
width int
height int
}
func (r testStruct) info() int{
return r.width * r.height
}
위 코드에서 (r testStruct) 부분이 리시버인것이다. 이 리시버를 통해서 info()메소드는 testStruct타입에 속했다는것을 알 수 있다. 이를 다른 객체지향언어 코드로 변환하면 아래의 코드들과 동일한 의미를 지닌 코드가 된다.
// In Java
class testStruct{
public int width;
public int height;
testStruct(int width,int height){
this.width = width;
this.height = height;
}
testStruct(){
this(10,20);
}
public int info(){
return this.width * this.height;
}
}
public class test{
public static void main(String[] args){
testStruct t = new testStruct();
System.out.println(t.info());
}
}
// In Python
class testStruct(object):
def __init__(self,width : int = 10, height : int = 20) -> None:
self.width = width
self.height = height
def info(self) -> int:
return self.width * self.height
if __name__ == "__main__":
t = testStruct(20,20)
print(t.info())
// In C++
#include <iostream>
using namespace std;
class testStruct{
public:
int width;
int height;
testStruct(int width, int height){
this->width = width;
this->height = height;
}
int info(){
return this->height * this->width;
}
};
int main(){
testStruct t = testStruct(20,20);
cout << t.info();
}
리시버로는 모든 로컬 타입이 가능하다. 로컬타입이란 해당 패키지 안에서 type키워드로 선언된 타입들을 말한다. 패키지 내에 선언된 구조체, 별칭 타입들이 리시버가 될 수 있다.
구조체 메소드 선언하기
예시 구조체 메소드를 선언해 보자
package main
import "fmt"
type account1 struct {
balance int
}
func (a *account1) withdrawPointer(amount int) {
a.balance -= amount
}
func withdrawFunc(a *account1, amount int) {
a.balance -= amount
}
func method1() {
a := &account1{100} // account1타입의 인스턴스 생성
withdrawFunc(a, 30)
a.withdrawPointer(10)
fmt.Println(a.balance)
}
withdrawPointer()함수를 보자. 이 함수는 *account 타입에 속한 메소드이다. *account타입의 인스턴스(구조체를 생성해 저장한 변수(데이터의 실체))들은 이 메소드를 사용할 수 있게 되는것이다. a는 account구조체 포인터 타입의 메소드이므로, account 구조체의 필드에 대해 '.'연산자를 통해 접근할 수 있다. 함수와 달리 메소드는 '(리시버 타입).(메소드)()' 형태로 접근한다.
이번에는 withdrawFunc()함수를 보자. void반환타입의 함수이며, 구조체 포인터 매개변수를 받아 해당 구조체의 데이터를 변경하는 일반적인 형식의 함수이다. 호출 방식도 일반 함수를 호출하듯이 '(함수명)()' 형태로 호출한다.
결론적으로 보면 메소드와 함수는 '함수의 역할' 을 하는데에 있어 공통점이 있지만, 활용 방향성(메소드는 각 리시버 타입의 함수라는 '응집성' 이있는반면 함수는 그런게 없다), 호출방식이 모두 다른것을 알 수 있다.
별칭 리시버 타입
일반적인 사용자 정의 타입도 리시버 타입이 될 수 있지만, 별칭타입도 리시버가 될 수 있다.
package main
import "fmt"
type myInt int
func (a myInt) add(b int) int {
return int(a) + b
}
func method3() {
var a myInt = 10
fmt.Println(a.add(20))
}
위에서도 설명했지만, 원시타입에 대해 별칭타입을 정의했더라도, 해당 원시타입과, 별칭타입간의 연산은 허용되지 않는다(GO언어는 타입에 매우 엄격한 언어임을 알아야한다, int64,int32타입간의 연산도 허용되지 않음). 엄연히 별개의 타입으로 해석한다.밑의 사진을 보면 별칭 myInt에 대해서는 add메소드 접근이 가능하지만 일반적인 int형인 b는 add메소드 접근이 되지 않는다.
포인터 메소드 vs 값 타입 메소드
리시버 타입에 대해 포인터로도 정의할 수 있지만, 값 타입으로 정의할 수 도 있다.
package main
import "fmt"
type account struct {
balance int
firstName string
lastName string
}
// 포인터 메소드
func (a1 *account) withdrawPointer(amount int) {
a1.balance -= amount
}
// 값 타입 메소드
func (a2 account) withdrawValue(amount int) {
a2.balance -= amount
}
// 변경된 값을 반환하는 값 타입 메소드
func (a3 account) withdrawReturnValue(amount int) account {
a3.balance -= amount
return a3
}
func method2() {
var mainA *account = &account{100, "Test", "hoplin"}
mainA.withdrawPointer(30)
fmt.Println(mainA.balance) // 70
mainA.withdrawValue(20)
fmt.Println(mainA.balance) // 70
var mainB account = mainA.withdrawReturnValue(20)
fmt.Println(mainB.balance) //50
mainB.withdrawPointer(30)
fmt.Println(mainB.balance)// 20
fmt.Println(mainA.balance) // 70
}
각 포인터 메소드, 값 타입 메소드, 값 반환타입 메소드 세가지의 특징을 살펴보자
- 포인터 메소드 : 포인터 메소드를 호출하면, 호출한 구조체의 메모리 주소가 복사된다. 복사된 주소에 있는 구조체에 대해 메소드 연산이 수행된다
- 값 타입 메소드 : 호출시 호출한 구조체의 필드가 새로운 구조체의 필드에 복사된다. 즉, 값 타입 메소드를 호출한 구조체 인스턴스와 메소드 내에서 사용하는 구조체 인스턴스는 서로 다른 구조체인것이다. 그렇기 때문에 위의 코드와 같이 값이 변하지 않는것이다.
- 값 반환타입 메소드 : 값 타입 메소드와 원리는 동일하나, 구조체를 반환한다는 점이 추가된다. 구조체를 반환하면, 반환된 구조체의 필드값들이 새로운 구조체 필드에 복사된후 반환받는 변수에 저장된다. 결론적으로 값이 변경된 구조체를 새로 받아 복사 형식이므로 값이 변경되긴 한다.
조금 쉽게 이해를 위해 값타입 메소드, 값 반환타입 메소드를 그림으로 그리면 아래와 같이 이해할 수 있다.