3. Go언어의 타입시스템

타입

타입 또는 이라고 부르는 개념은 프로그래밍에서 메모리에 저장된 데이터의 종류를 의미하며, 데이터를 어떻게 저장하고 해석할지를 결정한다.

예를들어 같은 정수라 하더라도 크기가 달라 메모리에서 차지하는 공간이 다를 수 있고
같은 데이터라도 정수일때와 문자일때 해석하는 방법이 다르다.

// 아래의 두 변수는 같은 "정수형" 변수 이지만 그 크기가 다르다.
// 같은 타입이라면 더 크기가 큰 자료형이 최대값, 최소값의 범위가 크다
var a int32 //32bit의 메모리 공간을 가진다.
var b int64 //64bit의 메모리 공간을 가진다.
// 아래의 두 변수는 메모리상에서 크기도 완전히 같은 데이터이지만
// 타입이 달라 표현하는 방식이 달라진다.
var a int32 = 65
var b rune = 'A'

타입 시스템

타입 시스템은 개발자가 코드를 작성할 때 프로그램의 동작과 유지보수를 더욱 예측 가능하게 만들어주는 중요한 개념으로 타입을 정의하고 조작하는 체계이다.

프로그래밍 언어에는 변수, 상수, 함수 매개변수, 함수 반환 값 등이 있고 이는 각자 다른 타입을 가지고 저장되고 표현된다.

타입 시스템은 이러한 타입들을 어떻게 부여, 해석, 추론할 것인지 정의한 시스템이다.

정적타입 (Statically Typed)

Go 언어는 정적 타입 언어이다.
정적 타입 언어는 변수, 상수, 함수 매개변수, 함수 반환 값 등의 타입이 컴파일 하는 시점에 결정되는 프로그래밍 언어를 말한다.

Go 언어에서는 변수를 선언할 때 변수의 타입이 결정되며 이는 절대 바뀌지 않는다.

// 각각 int, float64, string 타입의 변수 선언
var x int = 10
var y float64 = 5.5
var z string = "Hello"

// 변수의 타입을 변경하는 것은 불가능
x = 20.5 // Error cannot use 20.5 (type float64) as type int in assignment

마찬가지로 함수의 매개변수와 반환값 또한 컴파일 시점에 결정되어 있어야 하며 이를 명시적으로 표시 해 주어야한다.
다른 타입의 매개변수를 사용하거나, 반환하려하면 오류가 난다.

아래의 함수는 int타입의 매개변수를 받아 string타입의 데이터를 반환하는 함수이다.

func something(a int) string {
    return fmt.Sprintf("Hello, %d\n", a)
}

something("1") // Error: 매개변수의 타입이 맞지 않음

func something(a int) string {
    return 12345 // Error: 반환 타입이 맞지 않음
}

이처럼 Go 언어에서는 모든 데이터가 타입을 가지며 이는 컴파일 하는 시점에 이미 결정되어 있어야 한다.

타입 추론

Go 언어는 정적 타입 언어이다.
이는 Go 언어로 사용할 때 모든 데이터의 타입을 고려하고 명시적으로 표기해줘야함을 뜻한다.
이는 너무 불편하다. 그래서, Go 언어는 이 문제를 타입 추론으로 해결하고 있다.

타입 추론 이란 프로그래머가 변수의 타입을 지정하지 않아도 컴파일러가 자동으로 변수의 타입을 추론하여 지정하는 것을 말한다.

Go 에서는 선언과 동시에 초기화 하는 경우 타입 추론을 사용할 수 있다.
var =를 사용하거나 :=연산자를 사용하는 경우 변수의 타입을 명시적으로 지정하지 않고도 컴파일러가 우측의 값을 통해 자동으로 타입을 판단하여 변수의 타입으로 사용하게 된다.

보통의 경우 변수는 선언과 동시에 초기화 되기 때문에 Go 언어에서는 타입 추론을 적극적으로 사용할 수 있다.

어차피 직접 초기화 하지 않아도 기본값으로 초기화 되기 때문에 선언대입(:=) 연산자를 적극적으로 활용하는 것이 좋다

var a = 64 // 대입 연산자 우측 데이터가 정수형이기 때문에 기본 정수형인 int32 또는 int64로 변수의 타입이 정해짐

b := 64 // 위와 완전히 같음 (위의 선언의 축약형)

s := "string" // 대입 연산자 우측 데이터가 문자열이기 때문에 문자열 타입으로 변수가 선언됨

강타입 (Strongly Typed)

위에서 Go 언어에서 변수는 선언될 때 타입이 결정되며 이는 절대 바뀌지 않는다고 했는데 이는 강타입 언어의 특징이다.

Go 언어는 타입에 대해서 매우 엄격하다. 변수의 타입은 선언 시에 결정되며 이를 절대 바꿀 수 없다.

또한, Go 언어에서는 서로 다른 타입의 데이터간의 연산이 불가능하다. 이를 위해선 반드시 명시적인 형변환이 선행되어야 한다. 타입 안정성을 높여 예기치 못한 오류나 버그를 방지하는데 도움이 되지만 코딩하는데 더 신경써야하고 서로 다른 타입의 연산이 필요할 때 마다 명시적으로 형변환을 해야하는 번거로움이 있다.

// 정수형 변수 선언
var num1 int = 10
var num2 int = 20
    
// 정수형 변수끼리의 연산
sum := num1 + num2
fmt.Println("Sum:", sum)

// 형변환
var x int = 10
var y float64 = 5.5

// 정수형 변수를 실수형 변수로 명시적 형변환
result := float64(x) + y
fmt.Println("Result:", result)

만일 위에서 다음과 같이 명시적 형변환을 하지 않은 채 다른 타입 간의 연산을 시도한다면 컴파일 할 때 오류가 발생할 것이다.

// 정수형과 실수형을 혼합하여 연산하는데 명시적 형변환이 없음
result := x + y // 오류: 타입 불일치
fmt.Println("Result:", result)
invalid operation: x + y (mismatched types int and float64)

덕 타이핑 (Duck Typing)

턱 타이핑은 Go 언어의 중요한 특징 중 하나로 객체가 수행하는 동작에 초점을 맞춰 타입을 결정하는 시스템이다.

덕 타이핑은 앞에서 말한 변수와 함수에 관련된 이야기가 아닌
객체의 타입을 결정하는 방법에 관한 이야기이다.

아직 인터페이스에 대해 다루지 않았으므로 이번에는 간단한 개념만 살펴보고 자세한 설명은 다음에 하도록 한다.

덕 타이핑은 Duck Test에서 비롯된 개념으로

만약 어떤 새가 오리처럼 걷고, 오리처럼 소리를 내고, 오리처럼 행동한다면, 그것은 오리로 간주할 수 있다.

라는 개념에 착안하여 Go 언어에서는 객체의 타입을 결정할 때 어떤 인터페이스를 상속 해서 구현하는 지가 아닌 어떤 인터페이스의 메서드가 구현되어 있는가에 초첨을 맞추고 있다.

즉, 객체가 어떤 인터페이스의 메서드를 구현하고 있다면 그 인터페이스 타입으로 사용할 수 있는 것이다.

Go 언어의 타입들

기본 타입

Go 에는 기본적으로
논리, 정수, 부동소수점수, 문자열, 복소수 타입이 있다.

논리

bool 타입은 불리언(Boolean) 값을 표현하는 데 사용된다.
truefalse 두 가지 값 중 하나만 표현할 수 있으므로 가장 작은 크기인 1byte의 크기를 가진다.

var a bool = true
var b bool = false

정수

정수를 표현하는 타입이다.
가장 기본적으로는 부호가 있는 정수와 없는 정수로 나눌 수 있으며
필요한 범위에 따라 다양한 크기의 정수타입을 지원하고 있다.

기본적으로 부호가 있는 정수는 int로 표현하며 부호가 없는경우 unsigned를 뜻하는 u를 앞에 붙여 uint로 표현한다.
뒤에 숫자가 붙어 크기를 표현하는 경우도 있는데 8, 16, 32, 64 bit 의 크기로 나눌 수 있다.

Go 언어에서는 int와 uint의 크기가 플랫폼에 따라 다른데 일반적으로 32비트 또는 64비트 크기를 가진다.

var i int // 운영체제에 따라 크기가 다름 (32bit, 64bit)
var i8 int8 // 8bit(1byte) 부호있는 정수. -128 ~ 127.
var i16 int16 // 16bit(2byte) 부호있는 정수. -32,768 ~ 32,767
var i32 int32 // 32bit(4byte) 부호있는 정수. -2147483648 ~ 2147483647.
var i64 int64 // 64bit(8byte) 부호있는 정수. -9223372036854775808 ~ 9223372036854775807.

var ui uint // 운영체제에 따라 크기가 다름 (32bit, 64bit)
var ui8 uint8 // 8bit(1byte) 부호없는 정수. 0 ~ 255.
var ui16 uint16 // 16bit(2byte) 부호없는 정수. 0 ~ 65535
var ui32 uint32 // 32bit(4byte) 부호없는 정수. 0 ~ 4294967295.
var ui64 uint64 // 64bit(8byte) 부호앖는 정수. 0 ~ 18446744073709551615.

문자

또 다른 정수 타입으로는 문자가 있다.
왜 문자가 정수이냐 하면 컴퓨터는 문자를 정수로 변환하여 처리하기 때문이다.
이 때문에 Go언어는 따로 문자 타입을 두지 않고 정수타입에 따로 이름을 붙여 문자 타입으로 사용한다.

문자 타입에는 byte, rune이 있으며
byte는 ASCII 문자를 표현하기 위한 정수 타입으로 uint8과 같다. ASCII문자만을 지원하기 때문에 한글은 지원하지 않는다.
rune은 UTF8 문자를 표현하기 위한 정수 타입으로 int32와 같다. 한글을 지원한다.

var b byte
var r rune

부동소수점수

Go에서 실수를 표현하기 위해 IEEE 754 를 따르는 부동소수점수를 사용하며 단정도 부동 소수점배정도 부동 소수점을 지원한다.

var f32 float32
var f64 float64

문자열

문자열은 문자들이 연속으로 나열된 것으로 Go언어에서는 기본 타입으로 제공한다.
문자열은 큰따옴표 ("")로 감싸 표현할 수 있다.

Go 언어에는 문자열을 다루기 위한 유틸리티가 많다.
대표적으로 strings 패키지를 보면 문자열을 자르거나 치환하는 등의 다양한 도구를 제공하며
문자열을 다루는 아주 강력한 도구인 정규표현식을 사용할 수 있다.

var s string

복소수

Go는 다른 복소수를 기본 타입으로 다룰 수 있으며
복소수를 다루기 위한 다양한 유틸리티를 지원한다.

var c64 complex64 // 실수부(32bit) + 허수부(32bit)
var c128 complex128 // 실수부(64bit) + 허수부(64bit)

real 함수와 imag 함수를 사용하여 각각 실수부와 허수부를 추출 할 수 있다.

var c64 = 3 + 5i

fmt.Printf("real: %f, imag: %f\n", real(c64), imag(c64)) //출력 real: 3.000000, imag: 5.000000

또한 복소수 연산도 지원한다.

// 두 복소수 생성
var a complex128 = 2 + 3i
var b complex128 = 1 + 1i

// 덧셈 연산
sum := a + b
fmt.Println("Sum:", sum) // 출력: (3+4i)

// 곱셈 연산
product := a * b
fmt.Println("Product:", product) // 출력: (-1+5i)

컬렉션 (array, slice, map)

컬렉션이란 데이터를 모아둔 것이다.

Go언어에선는 기본적으로 array, slice, map 3가지의 컬렉션을 지원한다.
(구조체, 채널도 컬렉션의 일종이지만 그 성질이 다르므로 여기서는 다루지 않음)

순회

컬렉션을 다룰 때 개인적으로 가장 중요한 문법이 순회 라고 생각 하기 때문에 순회에 대해 먼저 짚고 넘어가겠다.

이 글에서 다룰 3가지의 컬렉션의 특징은 순회가능하다는 것이다.
순회란 어떤 집합이나 데이터 구조를 한 번씩 반복적으로 방문하고, 각 요소에 대해 작업을 수행하는 것을 의미한다.

예를 들어, 배열이나 슬라이스, 맵 등의 데이터 구조에 저장된 요소들을 순서대로 읽어들이거나, 그 요소들을 특정한 방식으로 조작하거나 처리하는 것이다.

Go 에서는 for 반복문과 range 키워드를 통해서 순회를 할 수 있다.

arr := []int{1, 2, 3, 4, 5}
for index, value := range arr {
    fmt.Printf("인덱스: %d, 값: %d\n", index, value)
}

Array (배열)

길이가 고정된 배열 이다.
여기서 고정된 길이 라는 말은 컴파일 시점에 길이가 정해져 있음을 말한다.
배열은 컴파일 시점에 정확한 길이가 정해져 있어야하며 Go언어 에서 길이가 다른 배열은 완전히 다른 타입으로 취급된다.

즉, 배열은 길이까지 포함해서 하나의 타입이다.

// 길이가 3인 배열 생성
var arr [3]int

// 리터럴로 길이가 3인 배열 생성과 동시에 초기화
arr := [3]int{1,2,3}
var arr [3]int // 3 은 배열의 길이

arr[0] = 1
arr[1] = 2
arr[2] = 3

arr[3] = 4 // 컴파일 오류: 배열의 범위를 벗어남
func something(a [5]int) {
    print(len(a))
}

var arr [3]int
something(arr) // 컴파일 오류: 배열의 길이가 다름, 완전히 다른 타입으로 취급됨

Slice (슬라이스)

슬라이스는 가변길이 배열 이다.
가변이라는 말 때문에 착각하기 쉽지만 배열 자체의 길이를 능동적으로 줄였다 늘렸다 할 수 있다는 뜻이 아니다.
배열과는 달리 컴파일 시점에 배열의 길이가 결정되지 않는다는 뜻이다.

Slice는 C99의 VLA (가변길이배열) 처럼 실행시점에 배열의 길이를 정해 할당하는 방식이다.
한번 만들어져 할당된 Slice는 그 길이를 바꿀 수 없고
Slice의 길이를 늘리고 싶다면 길이를 늘린 새로운 Slice를 만든 뒤 기존 내용물을 복사하는 방식을 사용해야한다.

길이를 늘리는 동시에 슬라이스 뒤에 데이터를 추가하고 싶다면
append 함수를 사용할 수 있다.
append 함수를 사용하면 기존의 슬라이스를 복사하여 길이를 늘리고 뒤에 데이터를 추가하여 새로운 슬라이스를 만들어준다.

Slicemake 함수를 통해 생성하거나 리터럴로 생성할 수도 있다.

// make로 길이가 3인 슬라이스 생성
arr := make([]int, 3)

// 리터럴로 생성
arr := []int{1,2,3}

슬라이스는 길이가 가변적이므로 길이에 변수를 넣을 수도 있다.

func makeSlice(l int) []int {
    s := make([]int, l) // 컴파일 시점에 길이를 알 수 없음
    
    for i := 0; i < l; i++ {
        s[i] = i + 1
    }
    
    return s
}

s := makeSlice(3)

fmt.Println(len(s)) // 실행 시점에 길이가 정해짐, 3

s[3] = 4 // 런타임 패닉 : index out of range, 한번 만들어진 Slice는 길이가 변하지 않음

newSlice := append(s, 4, 5) // 기존의 슬라이스의 길이는 증가하지 않음, 길이가 늘어난 새로운 슬라이스를 생성

fmt.Println(len(s))     // 길이: 3
fmt.Println(len(newSlice))     // 길이: 5

Slice는 길이가 가변적인 탓에 컴파일 시점에는 파악할 수 없는Runtime Panic을 일으킬 수 있으므로 사용에 각별한 주의가 필요하다.
사용할 때 항상 길이 체크를 하는 습관을 들이는 편이 좋다.

Map

MapKey-Value쌍의 집합으로 크기가 동적으로 늘었다 줄었다 할 수 있다.
Key는 하나의 Map에서 고유한 값이고 ValueKey와 1대1 대응되는 값이다.

예를 들어 유저 아이디유저 데이터의 관계같은 경우 Map 을 활용하면 쉽게 다룰 수 있다.

Go는 Hash Table을 통해 Map을 구현하고 있다.

Map또한 make함수를 통해 생성할 수 있다.

// 맵을 선언
var m map[string]int

// make 함수를 사용하여 맵 생성
m = make(map[string]int)

// 리터럴을 사용하여 맵 생성
m := map[string]int{"one": 1, "two": 2, "three": 3}

Map에 있는 정보를 가져오거나 추가, 삭제할 수 있다. Key가 존재하지 않아도 기본값을 반환하니 주의해야한다.
대신, Key가 존재하지 않으면 두번째 값으로 false를 반환한다.

a, ok := m["one"]
b, ok := m["uno"] //Key가 존재하지 않으면 기본값 0과 false가 반환된다.
m["four"] = 4 // Map에 데이터 추가
delete(m, "two") // Map에서 Key 가 0인 데이터를 삭제한다.

Map 또한 range 키워드로 순회할 수 있다.

for key, value := range m {
    fmt.Println("키:", key, "값:", value)
}

사용자 지정 타입

Go 언어에서는 기본 타입 외에 사용자 지정 타입을 사용할 수 있다.
Go언어에서는 type 이라는 키워드를 통해 기본 타입에 별칭을 붙여 사용하거나
인터페이스 (interface), 구조체 (struct) 등에 이름을 붙여 사용할 수 있다.

Go 에서는 type 키워드를 통해 기본 타입에 다른 이름을 붙여 사용할 수 있다.
용도에 따라 이름을 구분하거나, 타입의 정의가 긴 구조체, 인터페이스에 이름을 붙이기 위해 사용하는 편이다.

type 별칭 = 기본타입 의 형태로 사용한다.

type byte = uint8
type rune = int32

type Age = uint8

struct (구조체)

struct(구조체)는 여러 필드들의 집합으로 이전 글에서 설명한 컬렉션의 일종이다.
다만, 앞선 글의 컬렉션은 같은 타입. 즉, 같은 크기를 가진 데이터들의 집합이었다면 구조체는 서로 다른 크기를 가진 데이터들을 한 곳에 묶어 놓은 것이다.

struct 내부에는 여러개의 필드를 정의할 수 있으며 각각의 필드는 서로 다른 타입과 크기를 가질 수 있다.
최종적으로 구조체의 크기는 내부의 필드를 모두 합한 크기가 된다.

참고로 구조체는 그 자체로 타입이다.
마치 길이가 3인 배열은 [3]int 그 자체로 하나의 타입인 것 처럼
구조체 또한 선언된 모양 자체가 타입인 것이다.

var user struct {
    name   string
    age    uint8
    gender string
} // 괄호 안의 내용까지 포함해서 하나의 타입이다.

user = struct {
	name   string
	age    uint8
	gender string
}{"hwangseonu", 22, "male"}

하지만, 이렇게 사용하기에는 타입이 너무 길어진다는 단점이 있다.
그래서 struct는 보통 type 키워드와 결합하여 이름을 붙여 사용한다.

type Person struct {
    name string
    age uint8
    gender string
}

user := Person{"hwangseonu", 22, "male"}

interface

interface는 메서드의 선언을 모아놓은 집합이다.
메서드는 나중에 따로 설명할 테지만 간단히 설명하자면 특정 타입에 종속된 함수라고 생각하면 된다.

인터페이스는 이러한 메서드의 선언만을 모아서 만든 타입이다.

package main

import "fmt"

// Shape 인터페이스 정의
type Shape interface {
    // 면적을 계산하는 메서드
    Area() float64
}

// Rectangle 타입 정의
type Rectangle float64

// Rectangle이 Shape 인터페이스를 구현하는지 확인
func (r Rectangle) Area() float64 {
    return float64(r * r)
}

// Circle 타입 정의
type Circle float64

// Circle이 Shape 인터페이스를 구현하는지 확인
func (c Circle) Area() float64 {
    return 3.14 * float64(c * c)
}

func main() {
    // Rectangle과 Circle 객체 생성
    rectangle := Rectangle(5)
    circle := Circle(3)

    // Shape 인터페이스를 활용하여 면적 출력
    fmt.Println("사각형 면적:", CalculateArea(rectangle))
    fmt.Println("원 면적:", CalculateArea(circle))
}

// Shape 인터페이스를 받아서 면적을 계산하는 함수
func CalculateArea(shape Shape) float64 {
    return shape.Area()
}

메서드는 모든 타입에 구현할 수 있으며
특정 interface에서 요구하는 메서드를 올바르게 구현했다면 해당 interface타입으로 사용할 수 있다.

빈 인터페이스

Go 에는 아주 특수한 인터페이스가 있다.
바로 빈 인터페이스 이다. 아무것도 없는 상태를 의미하는 비어있는 인터페이스 라는 뜻이다

아무 메서드도 없는 빈 interface의 경우 모든 타입을 의미 할 수 있다.

생각해보면 빈 인터페이스는 아무 메서드도 구현하지 않아도 됨 을 의미한다.
즉, 어떤 타입이든 이미 비어있는 인터페이스를 구현하고 있는 것이다.

Go 언어에서는 주로 빈 인터페이스를 다른 언어의 Object, Super, Any 처럼 사용한다.

다음과 같은 예시를 들 수 있다.

// 임의의 타입을 다루는 함수
func PrintValue(value interface{}) {
    fmt.Println(value)
}

// 동적으로 타입을 확인하고 처리하는 함수
func ProcessValue(value interface{}) {
    switch v := value.(type) {
    case int:
        fmt.Println("정수:", v)
    case string:
        fmt.Println("문자열:", v)
    default:
        fmt.Println("다른 타입:", v)
    }
}

func main() {
    // 임의의 값을 출력하는 함수 호출
    PrintValue(42)
    PrintValue("Hello, world!")

    // 다양한 타입을 처리하는 함수 호출
    ProcessValue(42)
    ProcessValue("Hello, world!")
    ProcessValue(3.14)
}

끝마치며

오랜만에 글을 쓰는 것 같은데
아직 내용이 한참 남은 것 같다.
꾸준하게 글을 쓰겠다고 마음 먹었었지만 외적인 문제와 의지의 문제로 지속하기 어려웠었다. 작심삼일이라는 말처럼 금방 흐지부지 되고 말았다.

작심삼일도 3일마다 하면 그만이다.
앞으로는 꾸준히 글을 쓸 수 있도록 노력해보자.

results matching ""

    No results matching ""