a story

Go스터디: 4주차(18~22장) 본문

Book Study/Tucker의 Go Programming

Go스터디: 4주차(18~22장)

한명 2023. 10. 22. 13:47

이 글은 골든래빗 ‘Tucker의 Go 언어 프로그래밍의 18~22장 써머리입니다.

여기서 부터 주제가 조금씩 어려워지고 생각이 필요한 부분이 많습니다.

18. 슬라이스

일반적인 배열은 고정 길이를 가진다. 아래의 배열은 10개까지 값을 저장할 수 있다.

var array [10]int

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

다만 슬라이스를 초기화 하지 않으면 길이가 0인 슬라이스가 만들어 지는 것이기 때문에 임의로 인덱스를 접근하면 패닉이 발생한다.

package main

func main() {
	var slice []int

	slice[1] = 10
}

// 에러
panic: runtime error: index out of range [1] with length 0

goroutine 1 [running]:
main.main()
	/tmp/sandbox4095475310/prog.go:6 +0x14

슬라이스의 초기화와 요소 추가, 삭제

var slice1 []int{1,2,3} // 대괄호 안에 길이가 없다, {} 안에 요소값을 넣어서 초기화 할수 있다.
var array [...]int{1,2,3} // 고정길이 3인 배열이다. 슬라이스와 다르다.
var slice = make([]int,3) // 슬라이스는 make()를 통해서 초기화 할 수 있다.

// 슬라이스는 요소 추가를 위해 append()를 사용할 수 있다.
slice1 = append(slice1, 4)

// 슬라이스 요소 삭제를 위해 append()를 활용할 수 있다.
slice1 = append(slice[:idx], slice[idx+1:]...)

// 슬라이스의 중간에 요소 추가하기 위해 append()를 활용할 수 있다.
slice1 = append(slice[:idx], append([]int{100}, slice[idx:]...)...)

// 슬라이스 정렬
sort.Ints(slice1) // float64는 sort.Float64s() 사용

… 사용이 복잡하다 → 예시 확인

append()함수는 append(slice, 3,4,5)와 같이 첫 번째 인자로 주어진 slice에 여러 값을 추가할 수 있다. 아래의 예시에서도 append(slice, 요소,요소)와 같이 사용하기 위해, 여러 요소라는 의미로 slice…(슬라이스 전체)를 사용한 것이라고 이해하자.

package main

import "fmt"

func main() {
	var slice1 = []int{1, 2, 3}
	fmt.Println(slice1) //[1 2 3]

	//슬라이스 요소 추가
	slice1 = append(slice1, 4)
	fmt.Println(slice1) //[1 2 3 4]

	//슬라이스 요소 삭제
	idx := 1
	//slice1 = append(slice1[:idx], slice1[idx+1:])  //[에러] ./prog.go:13:38: cannot use slice1[idx + 1:] (value of type []int) as int value in argument to append
	slice1 = append(slice1[:idx], slice1[idx+1:]...) // 배열이나 슬라이스 뒤에 ...를 하면 모든 요소값을 넣어준 것과 같게 됨
	fmt.Println(slice1)                              //[1 2 3 4]

	//슬라이스의 중간에 요소 추가
	slice1 = append(slice1[:idx], append([]int{100}, slice1[idx:]...)...)
	fmt.Println(slice1) // [1 100 3 4]

}

예시에서는 슬라이싱(배열의 일부를 집어내는 기능)을 사용했다. 슬라이싱의 결과는 슬라이스이다.

array[startIdx:endIndx]

슬라이스는 배열의 일부를 나타내는 타입으로 포인터, len, cap 필드로 구성되어 있다. 포인터로 배열의 중간을 가리키고, len으로 포인터부터 일정 개수를, 그리고 cap은 포인터가 가리키는 배열이 할당된 크기(안전하게 사용할 수 있는 남은 배열 개수)를 나타낸다.

array := [5]int{1,2,3,4,5}
slice := array[1:2] // 이때 포인터는 array[1], len은 1, cap은 4가 된다.

slice1 := []int{1,2,3,4,5}
slice2 := slice1[:3] // 처음부터 슬라이싱
slice3 := slice1[2:] // 끝까지 슬라이싱

결과

package main

import "fmt"

func main() {
	slice1 := []int{1, 2, 3, 4, 5}
	slice2 := slice1[:3] // 처음부터 슬라이싱
	slice3 := slice1[2:] // 끝까지 슬라이싱

	fmt.Println(slice1)
	fmt.Println(slice2) // [1 2 3], 시작 인덱스 부터 슬라이싱
	fmt.Println(slice3) // [3 4 5], 끝 인덱스-1 까지 슬라이싱
}

슬라이스 동작 원리

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

type SliceHeader struct {
		Data uintptr // 실제 배열을 가리키는 포인터
	Len int        // 요소 개수
	Cap int        // 실제 배열의 길이
}

슬라이스와 배열 동작은 아래와 같은 차이가 있다.

package main

import "fmt"

// 배열은 모든 값이 복사되기 때문에, 함수내의 배열은 다른 배열이다.
func changeArray(array2 [5]int) {
	array2[2] = 500
}

// slice값이 복사되면 구조체의 각 필드값이 복사되어서 포인터의 메모리 주소값도 복사되고, 실제로 slice2는 복사되어도 같은 배열 데이터를 가리키게 된다.
func changeSlice(slice2 []int) {
	slice2[2] = 500
}

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

	changeArray(array)
	changeSlice(slice)

	fmt.Println(array) //[1 2 3 4 5]
	fmt.Println(slice) //[1 2 500 4 5]
}

 

19. 메서드

메서드(method)는 함수의 일종으로, 구조체 밖에서 메서드를 지정할 수 있다. 이때 특정 구조체의 메소드라는 것을 명시하기 위 리시버(reciver)를 func 키워드와 함수 이름 사이에 중괄호로 명시한다.

func (r Rabbit) info() int {
	return r.width * r.height
}

리시버로 모든 로컬 타입(해당 패키지 안에서 type 키워드로 선언된 타입)이 사용 가능하다. 기본 내장 타입도 사용자 정의 타입으로 별칭 타입으로 변환하여 메서드를 선언할 수 있다.

메서드는 왜 필요한가?

메서드는 리서버에 속한다. 메서드를 사용해서 데이터와 기능을 묶을 수 있게 된다.

아래의 예시와 같이 함수에 포인터 변수를 전달해서 동일한 목적을 수행할 수도 있는데, 왜 메서드를 이용할까?

package main

import "fmt"

type account struct {
	balance int
}

func withdrawFunc(a *account, amount int) { // 일반 함수 표현
	a.balance -= amount
}

func (a *account) withdrawMethod(amount int) { // 메서드 표현
	a.balance -= amount
}

func main() {
	a := &account{100} // balance가 100인 account 포인터 변수 생성

	withdrawFunc(a, 30) // 함수 형태 호출
	fmt.Println(a)  // 70 

	a.withdrawMethod(30) // 메서드 형태 호출
	fmt.Println(a)  // 40
}

예를 들어, ‘성적 입력 프로그램’을 만들 때, Student라는 구조체가 있다고 하면, 이 구조체의 필드로 이름, 반, 번호, 성적 등의 데이터가 있다. 메서드는 성적 입력, 반 배정 등의 Student 구조체의 기능을 나타낸다.

좋은 프로그래밍이라면 결합도(coupling, 객체간의 의존 관계)를 낮추고, 응집도(cohesion, 모듈 내 요소들의 상호 관련성)을 높여햐 한다. 메서드는 데이터와 관련된 기능을 묶기 때문에 코드 응집도를 높이는 중요한 역할을 한다. 응집도가 낮으면 새로운 기능을 추가할 때 흩어진 모든 부분을 검토하고 고쳐야 하는 문제가 발생한다. 응집도가 높으면 필요한 코드만 수정하면 된다.

현대의 프로그래밍에서는 함수 호출 순서보다 객체를 만들고, 다른 객체와 상호 관계를 맺는 것이 더 중요해 졌다. 이때 객체 간 상호 관계는 메서드로 표현된다.

리시버를 값 타입 vs. 포인터 타입 메서드

포인터 타입 메서드를 호출하면 포인터가 가리키고 있는 메모리 주소 값이 복사된다(서로 같은 주소값을 가지게 됨). 반면 값 타임 메서드를 호출하면 리시버 타입의 모든 값이 복사된다(서로 다른 주소값을 가지게 됨).

포인터 타입 메서드는 메서드 내부에서 리시버의 값을 변경시킬 수 있다. → 인스턴스 중심

값 타입 메서드는 호출하는 쪽과 메서드 내부의 값은 별도 인스턴스로 독립되기 때문에 메서드 내부에서 리시버의 값을 변경시킬 수 없다. → 값 중심

 

20. 인터페이스

인터페이스(interface)란 구현하지 않은 메서드 집합이다. 이를 이용하면 메서드 구현을 포함한 구체화된 객체(concrete object)가 아닌 추상화된 객체로 상호작용을 할 수 있다.

→ 구체화된 타입이 아닌, 인터페이스만 가지고 메서드를 호출할 수 있어, 추후 프로그램 요구사항 변경 시 유연하게 대체할 수 있다.

→ Go에서는 인터페이스 구현 여부를 그 타입이 인터페이스에 해당하는 메서드를 가지고 있는지로 판단한다(덕 타이핑: 타입 선언 시 인터페이스 구현 여부를 명시적으로 나타낼 필요 없이 인터페이스에 정의한 메서드 포함 여부만으로 결정한다).

Keyword: 인터페이스를 구현하면 인터페이스로 메서드를 호출할 수 있다.
프로그램의 요구사항 변경에 유연하게 대처 할 수 있다.

인터페이스 선언은 아래와 같다.

type DuckInterface interface {
	// 메서드 집합
	Fly()
	Walk(distance int) int
}

인터페이스 예시

package main

import "fmt"

type Stringer interface { // String 메서드를 가지면 뒤에 ~er 로 인터페이스명을 만든다.
	String() string
}

type Student struct {
	Name string
	Age  int
}

func (s Student) String() string { // Student의 String() 메서드가 Stringer 인터페이스를 구현함, Student 타입은 Stringer 인터페이스로 사용될 수 있다.
	return fmt.Sprintf("안녕! 나는 %d살 %s라고 해", s.Age, s.Name)
}

func main() {
	student := Student{"후후", 12}
	var stringer Stringer

	stringer = student // stringer 값으로 Student  타입 변수 student를 대입힌다. stringer는 Stringer 인터페이스이고 Stduent 타입은 String() 메서드를 포함하고 있기 때문에 stinger 값으로 stduent를 대입할 수 있다.

	fmt.Printf("%s\\n", stringer.String()) // stringer 인터페이스가 가지고 있는 메서드 String()을 호출한다. stringer 값으로 Stduent 타입 ㄴtudent를 가지고 있기 때문에 student의 메서드 String()이 호출되어 반환된다.
}

→ 인터페이스 타입 변수에 인터페이스를 구현한 타입 변수를 대입할 수 있다.

→ 인터페이스 타입 변수로 메서드를 호출하면, 이를 구현한 타입의 메서드를 호출 한다.

추상화

내부 동작을 감춰서 서비스를 제공하는 쪽과 사용하는 족 모두에게 자유를 주는 방식을 추상화라고 한다. 인터페이스는 추상화를 제공하는 추상화 계증(abstration layer)이다.

이렇게 추상화 계층을 이용해 서로 결합을 귾는 것을 디커플링(decoupling)이라 말한다. 결함도는 낮출 수록 좋다.

추상화 계층을 거치면, 내부구현을 알 수 없고 오직 인터페이스(메서드의 집합)만 알 수 있다. 이것을 객체 간 관계라고 정의할 수 있다. 택배회사와 택배 이용자는 택배 전송 관계로, 은행과 은행 이용자는 입금과 인출 관계로 상호작용 한다.

→ 구체화된 타입(내부 구현이 모드 있는 타입)으로 상호작용 하는 것이 아니라 관계로 상호작용 한다.

인터페이스의 특별한 기능

  • 포함된 인터페이스: 인터페이스가 다른 인터페이스를 포함하는 경우가 있다.
  • 빈 인터페이스 interface{}를 인수로 받기: 어떤 값이든 받을 수 있는 함수, 메서드, 변수값을 만들 때 사용한다.
  • 인터페이스 변수의 기본값은 유효하지 않은 메모리 주소를 나타내는 nil이다.
  • 인터페이스를 구체화된 다른 타입으로 타입 변환하려면 a.(ConcreteType)와 같이 구체화된 타입이나 다른 인터페이스로 변환 할 수 있다.

 

21. 함수 고급편

21.1 가변 인수 함수

fmt.Println() 과 같이 함수의 인수가 정해져 있지 않은 경우, 즉 함수 인수 개수가 고정적이지 않은 함수를 가변 인수 함수(variadic function)이라고 한다.

이런 함수는 … 키워드를 사용해서 가변 인수를 처리하면 된다.

package main

import "fmt"

func sum(nums ...int) int { // 가변 인수 함수는 ... 키워드로 가변 인수를 받는다.
	sum := 0

	fmt.Printf("nums의 타입: %T\\n", nums) // nums 의 타입은 []int (슬라이스) 이다.
	for _, v := range nums {
		sum += v
	}
	return sum
}
func main() {
	fmt.Println(sum(1, 2, 3)) 
	fmt.Println(sum(10, 20))
}

만약 다양한 인수를 섞어서 사용하고 싶다면, …interface{} 로 받을 수 있다. (모든 타입이 빈 인터페이스를 포함하고 있기 때문에 가능함)

package main

import "fmt"

func Print(args ...interface{}) {
	for _, arg := range args {
		switch arg.(type) {
		case bool:
			val := arg.(bool) // 인터페이스 변환
			fmt.Printf("type: %T\\n", val)
		case float64:
			val := arg.(float64) // 인터페이스 변환
			fmt.Printf("type: %T\\n", val)
		case int:
			val := arg.(int) // 인터페이스 변환
			fmt.Printf("type: %T\\n", val)
		}
	}
}
func main() {
	Print(2, true, 3.14)
}

21.2 defer 지연 실행

함수가 종료하기 직전에 실행해야 하는 코드가 있을 수 있다. 혹은 리소스를 사용하고 반납을 해야 하는 것과 같이 반드시 실행해야 하는 코드가 있다면, 리소스를 생성할 때 바로 defer를 사용해서 필요한 명령을 전달할 수 있다(흔히 발생하는 닫지 않은 리소스로 인한 leak을 방지한다).

defer 예시에서 주의할 점은 defer는 역순으로 호출된다. (아래는 3, 2, 1 순서로 출력된다)

package main

import (
	"fmt"
	"os"
)

func main() {
	f, err := os.Create("test.txt") // 파일 생성 -> 닫아줘야 한다
	if err != nil {
		fmt.Println("err")
		return
	}

	defer fmt.Println("1")
	defer f.Close() // defer로 처리해 놓으면 함수가 종료하기 전에 f를 닫아준다
	defer fmt.Println("2")
	fmt.Fprintln(f, "hello")
	defer fmt.Println("3")
}

21.3 함수 타입 변수

함수 타입 변수란, 함수를 값으로 갖는 변수를 의미한다.

함수는 시작 번지를 가지고, 함수가 시작되면 순차적으로 시작한다고 가정할 때, CPU에서는 프로그램 카운터(program counter)가 증가하며 다음 실행 라인을 나타낸다. 이때 main() 함수에서 f() 함수를 실행하면, 프로그램 카운터가 f()함수의 시작 지점을 가리키게 된다.

즉 함수 시작 지점이 함수를 가리키는 값이고, 마치 포인터 처럼 함수를 가리킨다고 해서 함수 포인터(function pointer)라고 부른다.

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

함수 add()를 카리키는 함수 포인터는 아래와 같이 표현한다.

func add(int, int) int

함수 타입 변수를 활용한 예제를 살펴본다.

package main

import (
	"fmt"
)

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

func mul(a, b int) int {
	return a * b
}

func getOperator(op string) func(int, int) int {
	if op == "+" {
		return add
	} else if op == "*" {
		return mul
	} else {
		return nil
	}
}

func main() {
	var operator func(int, int) int // int 타입 인수 2개를 받아서 int 타입을 반환하는 함수 타입 변수 operator 선언
	operator = getOperator("+")
	result := operator(1, 2)

	fmt.Println(result)
}

실제 활용 사례가 궁금하다.

 

21.4 함수 리터럴

함수 리터럴(function literal)은 이름 없는 함수로 함수명을 적지 않고 함수 타입 변수값으로 대입되는 함수값을 의미한다. (다른 언어의 익명함수 혹은 람다_Lambda와 동일하다)

예제를 살펴보면, 함수 타입 변수가 없이, 그냥 함수 자체를 정의한 것을 반환한다.

package main

import (
	"fmt"
)

type opFunc func(a, b int) int

func getOperator(op string) opFunc {
	if op == "+" {
		// 함수 리터럴을 사용해서 더하기 함수 자체를(함수 이름이 없어짐) 정의하고 반환
		return func(a, b int) int {
			return a + b
		}
	} else if op == "*" {
		return func(a, b int) int {
			return a * b
		}
	} else {
		return nil
	}
}

func main() {
	operator := getOperator("*")

	result := operator(1, 2)

	fmt.Println(result)
}

함수 리터럴은 필요한 변수를 내부 상태로 가질 수 있다. 함수 범위 내에서 유효할 것 같지만, 외부 변수를 실제로 변경할 수 있다.

함수 리터럴을 이용해서 원하는 함수를 그때그때 정의해서 함수 타입 변수값으로 사용할 수 있다. 한편 아래 예제에서 writeHello() 함수 입장에서는 인수로 Writer 함수 타입을 받는다. 실제로 어떤 동작을 할지는 호출했을 때 알 수 있게되는데, 이렇게 외부에서 로직을 주입하는 것을 의존성 주입(dependency injection)이라고 한다. 뭔가 아리송 하다.

package main

import (
	"fmt"
	"os"
)

type Writer func(string) // 함수 타입

func WriteHello(writer Writer) {
	writer("Hello World")
}

func main() {
	f, err := os.Create("test.txt")
	if err != nil {
		fmt.Println("failed")
		return
	}

	defer f.Close()

	WriteHello(func(msg string) {
		fmt.Fprintln(f, msg)
	})
}

 

22. 자료 구조

22.1 리스트

리스트(list)는 container 패키지에서 제공하는 자료 구조이다. 여러 데이터를 보관하는 배열과 비슷하다고 생각되지만, 배열이 연속된 메모리에 데이터를 저장하는 구조인 반면, 리스트는 연속되지 않는 메모리 공간에 데이터를 저장한다.

이러한 구조로 배열과 리스트는 데이터의 지역성(data locality)에 차이가 있다. 컴퓨터는 연산을 할 때 메모리에서 데이터를 가져와 캐시라는 임시 저장소에 보관하는데, 실제로 필요한 데이터만 가져오지 않고, 그 주변의 데이터를 가져온다. 보통은 높은 확률로 연산이 주변 데이터를 참조하기 때문에 효과적이다. 필요한 데이터가 인접해 있을 수록 데이터 처리 속도가 빨라지는데 데이터 지역성이 좋다고 한다. 배열은 연속된 메모리 이기 때문에 지역성이 리스트에 비해 좋다. 단, 요소 수가 적으면 데이터 지역성 때문에 배열이 효율적이지만, 삽입/삭제가 빈번한 연산이라면 리스트가 더 효율적이다.

리스트의 구조체 구조를 보면 요소들이 포인터로 연결된 링크드 리스트(Linked list) 형태인 것을 알 수 있다.

type Element struct {
	value interface{}
	Next *Element
	Prev *Element
}

다음 예제에서 List의 기본적인 사용법과 순회 방법을 살펴본다.

package main

import (
	"container/list"
	"fmt"
)

func main() {
	v := list.New()
	e4 := v.PushBack(4)
	e1 := v.PushFront(1)
	v.InsertBefore(3, e4)
	v.InsertAfter(2, e1)

	for e := v.Front(); e != nil; e = e.Next() { // Next() 메서드는 현재 요소의 다음 요소를 반환한다. 다음 요소가 없으면 nil 반환
		// (초기문)e는 v.Front() 부터; (조건문)e가 nil 값일 때까지; (후처리)e의 다음 요소로 넘어감
		fmt.Print(e.Value, " ")  // 1 2 3 4
	}

}

 

22.2 링

링(ring)은 container 패키지에서 제공하는 자료 구조이다. 리스트와 유사한 구조인데, 맨 뒤의 요소와 맨 앞의 요소가 서로 연결된 자료 구조이다.

기본 예제를 살펴본다.

package main

import (
	"container/ring"
	"fmt"
)

func main() {
	r := ring.New(5) // 요소가 5개인 링 생성

	//n := r.Len()

	// 순회하면서 모든 요소에 값 대입
	for i := 0; i < r.Len(); i++ {
		//r.Value = i // ring의 요소는 타입이 없나?, int를 넣으면 int가 저장됨.
		//r.Value = 'A' + i
		//이렇게 해도된다. 요소에 타입이 없는 것 같다.
		if i%2 == 0 {
			r.Value = 1
		} else {
			r.Value = '아'
		}
		r = r.Next()
	}

	for i := 0; i < r.Len(); i++ {
		fmt.Printf("%c ", r.Value)
		r = r.Next()
	}

}

이러한 원형 구조이기 때문에, 개수가 고정되고 오래된 요소는 지워도 되는 경우에 적합한 자료 구조이다. 예를 들어, 문서 편집기의 실행 취소 기능(일정 개수의 명령을 저장하고, 실행 취소 할 수 있음, 너무 오래된 명령은 지워짐)에서 사용할 수 있다.

 

22.3 맵

맵(map)은 키와 값(key/value) 형태로 데이터를 저장하는 자료 구조이다. 언어에 따라서 딕셔너리(dictionary), 해시테이블(hash table), 해시맵(hashmap) 등으로 부른다.

맵은 키와 값의 쌍으로 데이터를 저장하고, 키를 사용해 접근하여 값을 저장하거나 변경할 수 있다.

맵의 기본 예제를 살펴본다.

package main

import (
	"fmt"
)

type Product struct {
	Name   string
	Prince int
}

func main() {
	m := make(map[int]Product) // 맵 생성, map[key타입]value타입
	m[1001] = Product{"볼펜", 500}
	m[1002] = Product{"지우개", 500}
	m[1003] = Product{"연필", 500}
	m[1004] = Product{"샤프", 500}

	delete(m, 1002)
	delete(m, 1005) // 없는 값에 접근해도 에러가 발생하지는 않는다. v, ok := m[3] 으로 존재여부를 체크할 수 있다.

	for k, v := range m {
		fmt.Println(k, v)
	}
}

맵은 hash() 함수로 만들어진다. 해시 함수는 결과값이 항상 일정 범위(개수)를 가진다. 같은 입력에 대해서는 같은 결과를 보장하고, 일정 범위에서 반복된다.

그래서 입력값(key)을 hash()에 넣어 결과값을 인덱스로 사용해 배열에 값(value)를 넣는 방식으로 사용하면 맵과 같은 형태를 만들 수 있다. 물론 이런 단순한 구현에서는 다른 입력값으로 같은 결과가 나올 수 있는 함정이 있기 때문에 리스트를 활용할 수 있다. 그래도 hash() 정도의 연산으로 고정된 시간에서 데이터를 저장, 사용할 수 있는 장점이 있는 것 같다.

'Book Study > Tucker의 Go Programming' 카테고리의 다른 글

Go스터디: 7주차(31장)  (0) 2023.11.09
Go스터디: 6주차(27~30장)  (0) 2023.11.05
Go스터디: 5주차(23~26장)  (0) 2023.10.29
Go스터디: 3주차(12~17장)  (1) 2023.10.15
Go스터디: 2주차(3~11장)  (1) 2023.10.08