Go (4) - 슬라이스, 메서드, 인터페이스

슬라이스

 

일반적인 배열은 처음 배열을 만들 때 정한 길이에서 더 이상 늘어나지 않는 문제가 있습니다.

var arr [10]int

 

만약 처음 지정한 개수보다 더 많은 값을 저장하려면 더 큰 배열을 만들어서 값을 하나씩 복사해야 합니다. 이러한 문제점은 슬라이스를 사용하면 해결할 수 있습니다.

var slice []int

 

슬라이스는 배열과 비슷하지만 [] 안에 배열의 개수를 적지 않고 선언합니다.

슬라이스를 초기화하지 않으면 길이가 0인 슬라이스가 만들어집니다.

 

슬라이스 초기화

 

1. {} 로 초기화

var slice1 = []int{1, 2, 3}
var slice2 = []int{1, 5:2, 10:3}

 

  • slice1의 경우 1, 2, 3을 값으로 갖는 슬라이스가 됩니다.
  • slice2의 경우 첫 번째 요소는 1, 인덱스가 5인 요소는 2, 인덱스가 10인 요소는 3이 되고, 나머지 요소는 모두 기본값이 0이 되어, 총 11개의 요소를 갖는 슬라이스가 됩니다.

2. make() 로 초기화

var slice = make([]int, 3)

 

  • make() 내장 함수를 사용하여 첫번째 인자에 슬라이스 타입을 넣고, 두 번째 인자에 길이를 적어줍니다. 이때, 슬라이스의 요소값은 int 타입의 기본값이 0이 됩니다.

 

슬라이스의 요소

 

1. 요소 접근

슬라이스 요소에 접근하는 방법은 배열과 똑같습니다. 대괄호 [ ] 사이에 인덱스를 써서 각 요소에 접근합니다.

slice[1] = 5

 

2. 슬라이스 순회

슬라이스 순회 역시 배열과 똑같습니다. 동적으로 길이가 늘어나는 점만 제외하면 배열과 사용법이 같습니다.

var slice = []int{1, 2, 3}

for i := 0; i < len(slice); i++ {
	slice[i] += 10
}

for i, v := range slice {
	slice[i] = v * 2
}

 

3. 요소 추가

슬라이스만의 기능인 요소를 추가하는 append() 내장 함수의 첫 번째 인수를 추가하고자 하는 슬라이스와 두 번째 인수로 값을 전달하면, 슬라이스 맨 뒤에 요소를 추가해 만든 새로운 슬라이스를 결과로 반환합니다.

func main() {
    var slice = []int{1, 2, 3}

    slice = append(slice, 4)
    
    slice2 = append(slice, 5, 6, 7, 8)
}

 

슬라이스 동작 원리 (심화)

 

슬라이스는 내장 타입으로 내부 구현이 감춰져 있지만 reflect 패키지의 SliceHeader 구조체를 사용해 내부 구현을 살펴볼 수 있습니다.

type SliceHeader struct {
    Data uintptr
    Len int
    Cap int
}

 

슬라이스 구현은 배열을 가리키는 포인터(Data)와 요소 개수(Len)와, 전체 배열 길이(Cap)를 필드로 갖는 구조체입니다.

슬라이스가 실제 배열을 가리키는 포인터를 갖고 있어 쉽게 크기가 다른 배열을 가리키도록 변경할 수 있고, 슬라이스 변수 대입 시 배열에 비해서 사용되는 메모리나 속도에 이점이 있습니다.

 

◎ make() 함수의 인자

앞서 make() 함수로 초기화할 때 2개의 인자를 넣어서 사용했는데, 3개의 인자를 받을 수도 있습니다.

var slice = make([]int, 3)

var slice2 = make([]int, 3, 5)

남는 cap(capacity)에는 기본값이 할당되는 것이 아닌 비워져있는 것입니다.

 

◎ 슬라이스와 배열의 동작 차이

슬라이스 내부 구현이 배열과 다르기 때문에 동작도 배열과 매우 다릅니다. 다음 예시를 보면 결과가 다르게 나온 것을 확인할 수 있습니다.

package main

import "fmt"

func changeArray(array2 [5]int) {
	array2[2] = 200
}

func changeSlice(slice2 []int) {
	slice2[2] = 200
}

func main() {
    array := [5]int{ 1, 2, 3, 4, 5}
    slice := [5]int{ 1, 2, 3, 4, 5}
    
    changeArray(array)
    changeSlice(slice)
    
    fmt.Println(array)	// [1 2 3 4 5]
    fmt.Println(slice)	// [1 2 200 4 5]
}

 

Go 언어에서 모든 값의 대입은 복사로 일어납니다. 함수에 인수로 전달될 때나 다른 변수에 대입할 때나 값의 이동은 복사로 일어납니다.

 

changeArray() 함수가 호출될 때 array 변수에 할당된 배열이 매개변수인 array2로 복사됩니다. 배열은 값 타입이기 때문에 배열의 값 자체가 복사되므로, 복사한 배열인 array2를 변경한다고 해도 기존 배열인 array는 변경되지 않습니다. 

 

changeSlice() 함수가 호출될 때 slice 변수에 할당된 슬라이스가 매개변수인 slice2로 복사됩니다. 슬라이스는 참조 타입으로 내부 구조체에 실제 배열의 메모리 주소를 가리키는 포인터 필드가 복사되므로, 복사된 slice2가 바라보는 슬라이스의 요소를 변경하면 기존 슬라이스인 slice도 똑같이 변경된 슬라이스를 가리키게 됩니다.

 

◎ append() 사용 시 주의점

append() 함수는 슬라이스에 요소를 추가하는 내장 함수입니다. 그런데 append()는 해당 슬라이스의 남은 빈 공간 여부에 따라 다르게 동작합니다. 여기서 남은 빈 공간이란 cap - len 입니다. 남은 빈 공간이 있을 경우 추가되는 요소를 빈 공간에 채우게 됩니다. 이 경우 append() 함수가 반환하는 슬라이스의 포인터가 원본 슬라이스와 동일한 메모리 주소를 가리킵니다.

 

그러나 남은 빈 공간이 없을 경우 더 큰 새로운 배열을 만들고 기존 배열의 모든 요소를 새로운 배열에 복사하고 그 뒤에 새 요소를 추가합니다. 이 경우 append() 함수가 반환하는 슬라이스의 포인터는 원본 슬라이스와 다른 메모리 주소를 가리킵니다.

 

슬라이스 복제

 

앞서 슬라이스와 배열의 동작 차이에서 슬라이스는 참조 타입 변수이기 때문에 포인터가 복사되어 복사한 변수의 값을 변경하면 원본 배열이 변경되는 문제가 발생하는 것을 살펴보았습니다. 이런 문제가 발생하지 않도록 하는 방법은 항상 다른 배열을 가리키도록 슬라이스를 복제하는 것입니다.

package main

func main() {
    slice1 := []int{1, 2, 3, 4, 5}
    slice2 := make([]int, len(slice1))

    for i, v := range slice {
        slice2[i] = v
    }

    slice1[1] = 200
}

 

위와 같이 기존의 슬라이스의 길이만큼 새로운 슬라이스를 생성하고 range 순회를 통해 값을 넣어주면 다른 원본 배열을 가지는 슬라이스가 복제되게 됩니다.

 

 앞서 배운 append() 함수를 사용하면 더 간단하게 복제가 가능합니다.

slice2 := append([]int{}, slice1)

 

내장 함수인 copy() 를 사용하여 복제할 수도 있습니다. 반환값은 실제 복사된 요소의 개수입니다.

 

func copy(dst, src []type) int
pakcage main

fujnc main() {
    slice1 := []int{1, 2, 3, 4, 5}
    slice2 := make([]int, 3, 10)

    cnt1 := copy(slice2, slice1)	// 3
}

 

이 경우 cap의 값이 아닌 len의 값을 기준으로 복사하는 요소의 개수가 정해집니다.

 

슬라이싱

 

슬라이싱(slicing)은 단어 그대로 배열의 일부를 잘라내는 기능으로 해당 배열의 일부분만을 참조하고 싶을 때 사용합니다.

array[startIdx:endIdx]

array[startIdx:]

array[:endIdx]

array[:]

array[startIdx:endIdx:maxIdx]

 

기본적으로 startIdx <= x < endIdx 만큼의 배열 일부를 나타내는 슬라이스를 반환합니다.

슬라이싱의 범위가 처음 인덱스나 마지막 인덱스인 경우 startIdx와 endIdx를 생략할 수 있습니다.

 

array := [5]int{1, 2, 3, 4, 5}

slice := array[1:2]		// [2] : cap=4, len=1

slice = append(slice, 200)	// [2, 200] : cap=4, len=2

 

위와 같이 슬라이싱을 하면 2하나 만을 요소로 갖는 슬라이스가 반환됩니다. 이때 반환된 슬라이스의 포인터는 array 배열의 1번 인덱스 요소의 메모리 주소를 가리킵니다. 그리고 len = 1, cap = 4 를 필드로 갖습니다. 즉, 완전히 새로운 슬라이스를 반환하는 것이 아닌 기존의 배열을 바라보는 슬라이스를 반환합니다.

반환된 슬라이스는 cap - startIdx 만큼의 cap을 갖습니다. (maxIdx가 있는 경우 maxIdx - startIdx 만큼의 cap을 갖습니다.)

slice의 포인터가 기존 배열을 가리키기 때문에 슬라이스를 변경하면 기존 배열도 변경됩니다.

 

슬라이싱 기능을 배열뿐 아니라 슬라이스 일부를 집어낼 때도 사용할 수 있습니다.

slice1 := []int{1, 2, 3, 4, 5}

slice2 := slice1[1:2]	// [2] : cap = 4, len = 1

 

 

슬라이스 요소 삭제, 추가

 

앞서 배운 슬라이싱을 활용하면 슬라이스의 중간 요소를 삭제하거나 중간에 요소를 추가할 수 있습니다.

 

1. 요소 삭제

package main

func main() {
    slice := []int{1, 2, 3, 4, 5, 6}
    
    idx := 2;	// 삭제할 인덱스

    for i := idx+1; i < len(slice); i++ {
        slice[i-1] = slice[i]
    }

    slice = slice[:len(slice)-1]
}

 

삭제할 인덱스 뒤의 요소들을 한 칸씩 앞당겨주고, 마지막 인덱스를 슬라이싱 해줍니다.

이 또한 append() 함수를 사용하면 간단하게 표현할 수 있습니다.

slice = append(slice[:idx], slice[idx+1:]...)
append() 는 가변 인자를 인수로 받기 때문에 slice를 ...(unpacking)으로 펼쳐서 넣어주었습니다.

2. 요소 추가

package main

func main() {
    slice := []int{1, 2, 3, 4, 5, 6}

    slice = append(slice, 0)

    idx := 2	// 추가하려는 위치

    for i := len(slice)-2; i >= idx; i-- {
        slice[i+1] = slice[i]
    }

    slice[idx] = 100
}

 

맨 뒤부터 추가하려는 위치까지 한 칸씩 뒤로 밀어주고, 해당 위치에 값을 넣습니다.

이 또한 append() 함수를 사용하면 간단하게 표현할 수 있습니다.

slice = append(slice[:idx], append([]int{100}, slice[idx:]...)...)
append() 는 가변 인자를 인수로 받기 때문에 slice를 ...(unpacking)으로 펼쳐서 넣어주었습니다.

 

슬라이스 정렬

 

Go 언어에서 기본 제공하는 sort 패키지를 사용해 슬라이스를 정렬할 수 있습니다.

package main

import "sort"

func main() {
    s := []int{5, 2, 6, 3, 1, 4}
    sort.Ints(s)
}

 

 

메서드

 

Go 언어에서는 함수(function)와 메서드(method)를 구분하여 사용합니다.

Go 언어에는 클래스와 상속을 지원하지 않고 메서드를 통해 특정 타입(리시버)에 함수를 귀속시켜 객체지향 프로그래밍(OOP)이 가능하게 해줍니다.

 

메서드 선언

 

메서드를 선언하려면 리시버를 func 키워드와 함수명 사이에 소괄호로 명시해야 합니다.

func (p *Person) info() int {
    return r.age	
}

 

리시버로는 모든 로컬 타입들이 가능한데, 로컬 타입이란 해당 패키지 안에서 type 키워드로 선언된 타입들을 말합니다. 따라서 패키지 내 선언된 구조체, 별칭 타입들이 리시버가 될 수 있습니다. 이때 구조체 변수 p는 해당 메서드 안에서 매개변수처럼 사용됩니다. 

package main

type myInt int

func (a myInt) add(b int) int {
    return int(a) + b
}

 

모든 로컬 타입이 리시버 타입으로 가능하기 때문에 위 코드와 같이 별칭 타입도 리시버가 될 수 있습니다.

 

값 타입 리시버 vs 포인터 리시버

 

package main

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 main() {
    var mainA *account = &account{ 100, "Joe", "Park" }
    var mainB account = account{ 200, "Jin", "Lee" }
    
    mainA.withdrawPointer(30)
    mainA.withdrawValue(20)
    
    mainB.withdrawPointer(30)
    mainB.withdrawValue(20)
}

 

withdrawPointer() 메서드는 리시버로 포인터를, withdrawValue() 메서드는 리시버로 값 타입을 갖습니다.

Go 언어에서는 자동으로 값 타입 리시버와 포인터 리시버가 호환되도록 변환해줍니다.
구조체를 수정해야 하거나 복사 비용을 아끼기 위해서 일반적으로 포인터 타입 리시버를 사용합니다.

 

 

인터페이스

 

인터페이스를 이용하면 메서드 구현을 포함한 구체화된 객체가 아닌 추상화된 객체로 상호작용이 가능해집니다.

 

인터페이스 선언

 

type Personer interface {
    eat()
    Walk(distance int) int
}

 

인터페이스도 구조체처럼 타입 중 하나이기 때문에 type을 붙여줘야 합니다. 이 말은 인터페이스 변수 선언이 가능하고 변수의 값으로 사용할 수 있다는 뜻입니다.

일반적으로 인터페이스명은 -er 을 붙여서 작성합니다.
Go 언어에서는 오버로딩 함수나 메서드를 지원하지 않습니다.

 

인터페이스 구현

 

많은 언어들에서는 인터페이스를 구현할 때 명시적으로 implements 키워드를 선언해서 사용합니다.

그러나 Go 언어에서는 덕 타이핑 방식을 사용합니다.

 

덕 타이핑 방식이란 인터페이스 구현 여부를 명시적으로 나타낼 필요 없이 인터페이스에 정의한 메서드 포함 여부만으로 결정하는 방식입니다. 이런 덕 타이핑의 장점은 한 마디로 서비스 사용자 중심의 코딩을 할 수 있다는 점입니다.

 

인터페이스의 구현 여부를 타입 선언에서 하는 게 아니라 인터페이스가 사용될 때 해당 타입이 인터페이스에 정의된 메서드를 포함했는지 여부로 결정합니다. 따라서 서비스 제공자가 인터페이스를 정의할 필요 없이 구체화된 객체만 제공하고 서비스 이용자가 필요에 따라 그때그때 인터페이스를 정의해서 사용할 수 있습니다.

 

import "fmt"

type Speaker interface {
    Speak()
}

type Dog struct{}

func (d Dog) Speak() {
    fmt.Println("멍멍")
}

 

위 코드에서 Dog 타입은 Speaker 인터페이스를 명시적으로 구현하지 않았지만, Speak() 라는 메서드를 갖고 있으니 Speaker 인터페이스를 만족한다고 표현합니다.

이러한 덕 타이핑 방식의 암묵적 구현은 한 번 구현을 선언하면 계속 종속되는 선언형 구현과 달리 사용할 때마다 구현 여부를 선택할 수 있습니다. 이러한 구조가 더 유연하고 코드가 분리되어 있어 테스트도 용이합니다.

 

다양한 인터페이스 형태

 

1. 포함된 인터페이스

type Reader interface {
    Read() (n int, err error)
    Close() error
}

type Writer interface {
    Write() (n int, err error)
    CLose() error
}

type ReadWriter interface {
    Reader		// Reader의 모든 메서드 집합을 포함
    Writer		// Writer의 모든 메서드 집합을 포함
}

 

위와 같은 인터페이스들이 있을 때 어떻게 구현할 수 있는지 살펴보겠습니다.

  • Read(), Write(), Close() 포함: Reader, Writer, ReadWriter 모두 사용 가능
  • Read(), Close() 포함: Reader만 사용 가능
  • Write(), Close() 포함: Writer만 사용 가능
  • Read(), Write() 포함: 모두 사용 불가능

 

2. 빈 인터페이스

interface{} 는 메서드를 갖고 있지 않은 빈 인터페이스입니다. 가지고 있어야 할 메서드가 하나도 없기 때문에 모든 타입이 빈 인터페이스로 쓰일 수 있습니다. 빈 인터페이스는 어떤 값이든 받을 수 있는 함수, 메서드, 변숫값을 만들 때 사용합니다.

package main

func PrintVal(v interface{]) {
    switch t := v.(type) {
        case int:
            //
        case float64:
            //
        case string:
            //
        defualt:
            //
    }
}

 

 

3. 인터페이스 기본값

인터페이스 변수의 기본값은 유효하지 않은 메모리 주소를 나타내는 nil 입니다.

package main

type Attacker interface {
    Attack()
}

func main() {
    var att Attacker
    att.Attack()
}

 

위 코드와 같이 작성하고 실행하면 런 타임 에러(runtime error)가 발생합니다. att의 값이 유효하지 않은 메모리 주소인 nil 이기 때문에

invalid memory address 라는 에러가 발생합니다.

 

Compile Error vs Runtime Error

컴파일 타임 에러는 코드를 기계어로 전환하여 실행 파일로 만드는 중에 발생한 에러로 주로 문법 오류에서 발생합니다.
런타임 에러는 실행 도중 예기치 않은 문제로 발생하는 에러로 문법에는 문제가 없으나 앞의 예시와 같이 값이 비정상적인 경우에 발생합니다.

 

타입 단언

 

인터페이스 변수를 타입 변환을 통해서 구체화된 다른 타입이나 다른 인터페이스로 변환할 수 있습니다.

 

1. 구체화된 다른 타입으로 타입 단언

이 방법은 인터페이스를 본래의 구체화된 타입으로 복원할 때 주로 사용합니다. 사용 방법은 인터페이스 변수 뒤에 점(.)을 찍고 소괄호() 안에 변경하려는 타입을 써주면 됩니다.

var a Interface
t := a.(ConcreteType)
package main

type Personer interface {
    Eat() string
}

type Student struct {
    Age int
}

func (s *Student) Eat() string {
    // 문장
}

func GetAge(personer Personer) int {
    s := personer.(*Personer)
    return s.Age
}

func main() {
    s := &Student{15}

    GetAge(s)
}

 

Java에서 매개변수로 인터페이스를 받고 내부에서 구현 클래스로 다운 캐스팅해주는 것과 비슷합니다. 

Personer 인터페이스는 Eat() 메서드만 포함하고 있기 때문에 personer 변수로는 Age 필드에 접근할 수 없습니다. 그래서 Eat() 메서드를 포함해 Personer 인터페이스로 사용될 수 있는 Student 타입으로 타입 변환을 한 후 접근해야 합니다.

 

2. 다른 인터페이스로 타입 단언

인터페이스 변환을 통해 구체화된 타입뿐 아니라 다른 인터페이스로도 타입 변환할 수 있습니다. 이 경우에는 구체화된 타입으로 변환할 때와는 달리 변경되는 인터페이스가 변경 전 인터페이스를 포함하지 않아도 됩니다. 하지만 인터페이스가 가리키고 있는 실제 인스턴스가 변환하고자 하는 다른 인터페이스를 포함해야 합니다.

그림과 같이 ConcreteType이 AInterface와 BInterface를 모두 포함하고 있는 경우에 다른 인터페이스로 변환이 가능합니다.

var a AInterface = ConcreteType{}
b := a.(BInterface)

 

◎ 타입 단언 성공 여부

타입 변환이 아예 불가능한 타입이라면 컴파일 타임 에러가 발생하기 때문에 미리 감지가 가능하지만, 문법적으로는 문제가 없는데 실행 도중 타입 변환에 실패하는 경우에는 런타임 에러가 발생합니다. 

var a Interface
t, ok := a.(ConcreteType)

 

위 코드처럼 타입 변환 반환값을 두 개의 변수로 받으면 타입 변환 가능 여부를 두 번째 반환값(boolean)으로 알려줍니다.

이때 타입 변환이 불가능하더라도 두 번째 반환값이 false로 반환될 뿐 런 타임 에러는 발생하지 않습니다.

이러한 방법은 런타임 에러를 미연에 방지할 수 있기 때문에 인터페이스 변환 시 항상 변환 여부를 확인하는 것을 추천합니다.
func ReadFile(reader Reader) {
    c, ok := reader.(Closer)
    if ok {
        c.Close()
    }
}

func ReadFile(reader Reader) {
    if c, ok := reader.(Closer); ok {
    	c.Close()
    }
}

 

타입 변환 결과를 반환받아서 변환 성공 여부를 검사하는 if문을 두 번째 코드처럼 한 줄로 표현할 수도 있습니다.