고랭에서의 배열(Array)은 동일한 타입의 요소들이 연속된 메모리 위치에 저장되는 고정 길이의 시퀀스입니다.
배열은 여러 개의 값을 하나의 변수 아래에 저장할 수 있게 해주며, 각 요소는 인덱스를 통해 접근할 수 있습니다.
고랭에서의 배열은 선언 시 크기가 고정되며, 선언된 후에는 크기를 변경할 수 없습니다.
고랭에는 여러 가지 컬렉션 타입이 있다.
https://pkg.go.dev/github.com/chenhg5/collection
컬렉션 타입은 여러 값을 하나의 변수에 저장할 수 있도록 하는 '데이터 구조'다.
고랭에서 컬렉션 타입에는 다음과 같은 것들이 있다.
배열(Array) > 고정된 크기를 가지며 동일한 타입의 여러 요소를 순서대로 저장한다. 선언 시 지정된 크기를 변경할 수 없다.
슬라이스(Slice) > 내부적으로 배열을 사용하는데 용량을 넘어서는 요소가 추가되면 자동으로 크기가 조정된다.
맵(Map) > 키:값 쌍으로 데이터를 저장한다. 맵은 키를 통해 빠르게 값을 검색할 수 있는 구조를 제공하는데 키는 고유해야 한다.
채널(Channel) > 고루틴(goroutines) 간에 데이터를 주고받기 위한 통신 메커니즘이다. 채널을 통해 고루틴 사이에서 데이터를 동기화하며 통신할 수 있다.
대부분의 프로그래밍 언어에서 배열의 크기는 고정되어 있다.
그러나 일부 언어는 크기가 동적으로 변할 수 있는 배열이나 배열과 유사한 자료구조(자바에서의 ArrayList, 파이썬의 Lists)를 제공한다.
배열(Array)은 고정된 크기를 가지고 메모리에 연속적으로 할당되는 반면, 리스트(List)는 일반적으로 크기가 동적으로 변하고 메모리에 연속적이지 않을 수 있다.
리스트는 추가, 삭제 등의 작업이 빈번할 때 유용하며, 배열은 인덱스를 통한 빠른 접근이 필요할 때 사용된다.
<잠깐!>
그럼 리스트에는 인덱스가 없나?
리스트에서 인덱스 개념은 리스트의 종류와 구현 방식에 따라 다를 수 있다.
예를 들어, 배열 기반의 리스트(고랭의 Slice, 자바의 ArrayList, 파이썬의 Lists)에서는 인덱스를 사용해 각 요소에 접근할 수 있다.
이런 리스트는 내부적으로 배열을 사용해 요소를 저장하기 때문이다.
반면에 연결 리스트(LinkedList)와 같은 다른 형태의 리스트에서는 인덱스 개념이 명시적으로 사용되지 않을 수 있다.
연결 리스트는 각 요소(노드)가 다음 요소를 가리키는 방식으로 구성되어 특정 인덱스 요소에 접근하기 위해 처음부터 해당 요소까지 순차적으로 탐색해야 한다.
따라서 연결 리스트에서는 인덱스를 기반으로 한 빠른 접근이 어렵고, 주로 요소의 추가와 삭제가 빈번할 때 유용하게 사용된다.
고랭의 "container/list" 패키지에서 제공하는 리스트는 연결 리스트의 형태로 구현되어 있으며, 인덱스를 사용하지 않고 순회하거나 특정 위치에 요소를 추가, 삭제하는 방식으로 작동한다.
배열처럼 크기를 고정하는 데이터 타입이 있는 이유는 메모리 관리와 성능 최적화에 있다. 데이터의 크기가 고정되면 컴파일러는 메모리 할당과 관리를 보다 효율적으로 할 수 있고 배열의 요소에 대한 접근 시간을 일정하게 유지할 수 있다.
고랭에서 서로 다른 데이터 타입의 값을 담으려면 interface{} 타입을 쓰면 된다.
interface{}는 고랭의 빈 인터페이스로, 어떠한 타입의 값도 저장할 수 있으며, 동적 타입을 사용하는 경우에 유용하다.
struct 타입을 사용해도 서로 다른 데이터 타입을 필드로 가지는 복합 데이터 타입을 정의할 수 있다.
struct를 사용하면 각 필드에 특정 타입의 데이터를 저장할 수 있어, 다양한 타입의 데이터를 하나의 구조체 내에 조직화할 수 있다.
<잠깐!>
데이터 타입과 자료구조의 차이
데이터 타입 > 데이터의 종류, 이 데이터가 메모리에서 차지하는 공간의 크기와 해당 데이터를 처리하는 방법을 결정한다.
기본 데이터 타입에는 정수형, 실수형, 불리언, 문자열 등이 있으며, 복합 데이터 타입에는 배열, 구조체, 인터페이스 등이 있다.
데이터 타입은 변수가 저장할 수 있는 값의 종류와 연산을 결정한다.
자료구조 > 데이터를 효율적으로 저장하고 관리하기 위한 데이터의 조직, 관리, 저장 구조를 말한다.
데이터를 어떻게 저장하고 접근할 수 있게 할지에 대한 메커니즘을 제공하며, 배열, 연결 리스트, 스택, 큐, 트리, 그래프, 해시 테이블 등이 있다.
자료구조는 특정 문제를 해결하기 위한 데이터의 표현 및 조작 방법에 초점을 맞춘다.
배열 변수 선언 및 초기화
선언: 배열을 선언할 때는 배열이 저장할 요소의 타입과 배열의 크기를 명시해야 한다. 크기는 상수이며, 선언 후 변경할 수 없다.
var arr [5]int
초기화: 배열을 선언함과 동시에 초기화할 수 있다. 모든 요소에 대해 초기값을 설정할 수도 있다.
arr := [5]int{1, 2, 3, 4, 5}
배열 선언 시 크기가 상수로 지정되어야 하는 까닭은, 컴파일 타임에 배열의 크기가 결정되기 때문이다.
런타임에 크기를 결정하는 동적 배열이 필요한 경우에는 슬라이스를 사용하면 된다.
배열의 요소 읽고 쓰기:
읽기: 배열의 특정 요소에 접근하려면 인덱스를 사용한다. 인덱스는 0부터 시작한다.
value := arr[2] // 3번째 요소에 접근
쓰기: 배열의 특정 요소를 수정하려면 인덱스를 사용하여 해당 요소에 새 값을 할당한다.
arr[2] = 10 // 3번째 요소의 값을 10으로 변경
range 순회:
range 키워드를 사용하면 배열의 모든 요소를 순회할 수 있다. range는 각 반복에서 인덱스와 해당 인덱스의 값을 반환한다.
for index, value := range arr {
fmt.Println("Index:", index, "Value:", value)
}
인덱스가 필요하지 않은 경우에는 '_'를 넣어서 인덱스를 무시할 수 있다.
for _, value := range arr {
fmt.Println("Value:", value)
}
배열의 모든 요소는 메모리 상에서 서로 인접한 위치에 순차적으로 저장된다.
이는 배열의 특징 중 하나로, 배열을 구성하는 각 요소가 메모리 상에서 연속적인 주소 공간을 차지한다는 말과 같다.
이런 특성 때문에 배열은 특정 요소에 매우 빨리 접근할 수 있다. 컴퓨터가 배열의 시작 주소를 알고 있으며, 각 요소의 위치를 쉽게 계산할 수 있기 때문이다. 예를 들어, 배열의 첫 번째 요소의 메모리 주소가 주어지고 각 요소의 크기가 알려져 있다면, n번째 요소의 메모리 주소는 배열의 시작 주소에 'n * 요소의 크기'를 더한 값으로 계산할 수 있다.
연속된 메모리 할당은 배열의 인덱스를 통한 빠른 데이터 접근을 가능하게 하지만, 이는 또한 배열의 크기를 런타임에 변경하기 어렵기 만든다.
컴파일 타임에 이미 배열의 크기가 정해졌다면, 배열의 크기를 바꾸기 위해서는 새로운 연속된 메모리 공간을 할당하고 기존 데이터를 새 위치로 복사해야 하기 때문이다. 이러한 제약 때문에 더 동적인 데이터 구조가 필요한 경우에는 배열 대신 슬라이스나 다른 자료구조를 사용하는 게 일반적이다.
고랭에서 배열 복사를 왜 하나?
1. 데이터 격리: 함수에 배열을 전달할 때 원본 데이터를 보호하고 싶은 경우.
2. 상태 복원: 특정 작업 전의 배열 상태를 저장해두었다가 필요 시 해당 상태로 배열을 복원하고 싶을 때. 예를 들어, 정렬 전 원본 배열의 순서를 보존하고 싶을 때.
3. 병렬 처리: 배열의 데이터를 여러 고루틴에서 동시에 처리해야 할 때, 각 고루틴에 배열의 복사본을 전달함으로써 데이터 경쟁(data race)을 방지할 수 있다.
4. 불변성 유지: 함수나 메서드의 인자로 배열을 전달할 때, 해당 함수나 메서드가 배열을 변경하지 않도록 보장하고 싶은 경우.
5. 버전 관리: 데이터의 이전 버전을 유지하고 싶을 때, 배열의 상태를 복사하여 다른 변수에 저장함으로써 데이터의 스냅샷을 관리할 수 있다.
거의 다 같은 얘기다.
<잠깐!>
배열은 값 타입이라는데 값 타입이 뭐지?
값 타입은 데이터 타입의 한 분류로, 변수에 실제 데이터 값이 저장되는 타입을 말한다.
값 타입의 변수를 다른 변수에 할당하거나 함수에 인자로 전달하면 해당 값의 복사본이 생성되어 전달된다.
원본 데이터와 복사된 데이터가 서로 독립적이라는 말이다.
변경 사항이 원본에 영향을 미치지 않는다.
고랭에서의 기본 데이터 타입(정수, 실수, 불리언 등)과 구조체, 배열 등은 값 타입이다.
반면에 슬라이드, 맵, 채널 등은 참조 타입(reference type)이다.
참조 타입은 데이터의 참조(메모리 주소)를 저장하고 전달하는 타입이다.
값 타입과 참조 타입의 주요 차이는 데이터를 전달할 때의 동작 방식에 있다.
값 타입은 데이터의 복사본을 생성하여 전달하는 반면, 참조 타입은 메모리 주소를 전달하여 여러 변수나 함수가 동일한 데이터를 공유할 수 있게 한다.
값 타입의 설명을 처음 봤을 때, 나는 '어? 이거 상수 같은 건가?'라고 생각을 했는데 그렇지 않았다.
값 타입과 상수의 차이에 대해서는 아래에 소개한다.
값 타입:
데이터가 변수에 직접 저장되는 타입이다.
값 타입의 변수는 메모리 상에서 실제 값을 가진다.
이 값을 다른 변수에 복사할 때는 그 값의 복사본이 생성되어 전달된다.
값 타입의 변수를 다른 변수에 할당하면, 원본 값의 완전한 복사본이 새 변수에 저장된다.
따라서, 한 변수에서의 변경이 다른 변수에 영향을 주지 않는다.
상수: 값이 변하지 않는 변수로 프로그램의 실행 동안 일정한 값을 유지한다.
상수는 값 타입일 수도 있고, 참조 타입의 일부일 수도 있다(예: 문자열 상수).
결론적으로, 값 타입은 변수가 데이터를 저장하는 방식에 관한 것이고, 상수는 변수의 값이 변경될 수 있는지 여부에 관한 것이다.
+문자열이 참조 타입이었구나...
고랭에서 문자열을 참조 타입을 분류하는 것은 그 구현 방식 때문이다.
고랭의 문자열은 불면(immutable)하며, 내부적으로 두 부분으로 구성된다.
- 데이터 포인터: 실제 문자열 데이터가 저장된 메모리 위치를 가리키는 포인터다.
- 길이: 문자열의 길이를 나타내는 부분이다.
이런 구조 때문에 고랭의 문자열은 한 번 생성되면 그 내용을 변경할 수 없다.
문자열에 대한 연산이 수행될 때(예: 문자열 결합, 문자열 추출 등), 기존 문자열 데이터를 변경하는 대신 새로운 문자열이 생성되고, 새 데이터 포인터와 길이가 해당 문자열 변수에 할당된다.이러한 접근 방식이 메모리 사용 효율을 높인다는데... 이 부분은 아직 머릿 속에 잘 그려지지 않는다.
Go에서 문자열을 사용할 때는 값 타입처럼 느껴질 수 있다. 예를 들어, 문자열을 함수 인자로 전달하면 해당 문자열의 "값"이 전달되는 것처럼 보인다. 그러나 내부적으로는 문자열 데이터의 메모리 주소가 전달되며, 문자열의 불변성으로 인해 원본 문자열이 변경될 위험이 없다. 따라서, 문자열을 고성능으로 안전하게 다룰 수 있다.
메모리 효율성에 대해 조금만 정리하고 간다.
값 타입의 메모리 효율성: 실제 데이터 값을 메모리에 직접 저장한다. 데이터의 크기가 작고 복사가 자주 발생하지 않을 때 높다. 정수나 불리언 같은 작은 데이터 타입은 값 타입으로 사용할 때 메모리 오버헤드가 거의 발생하지 않는다. 값 타입의 데이터가 크거나 복사가 자주 발생하면 메모리 사용량이 증가하고 성능에 부정적인 영향이 갈 수 있다.
참조 타입의 메모리 효율성: 데이터의 메모리 주소를 저장한다. 데이터의 크기가 클 때 또는 데이터를 여러 변수나 함수에서 공유해야 할 때 특히 높다. 예를 들어, 큰 문자열이나 큰 슬라이스는 참조 타입으로 관리할 때, 메모리 사용량을 크게 줄일 수 있다.
고랭에서의 다중 배열(multi-dimensional arrays)은 배열의 배열이다.
흔히들 2차원 배열, 3차원 배열이라 말하는 그것.
다중 배열은 행렬(matrix)이나 데이터 테이블 같은 복잡한 데이터 구조를 표현할 때 사용된다.
선언 방법:
var matrix [3][2]int
왼쪽으로 행-렬로 읽으면 된다.
3행 2열의 2차원 배열인 것.
초기화 방법:
matrix := [3][2]int{
{1, 2},
{3, 4},
{5, 6},
}
다중 배열 요소에 접근하는 법:
인덱스를 사용하면 된다.
var value = matrix[1][0] // 2행 1열의 값을 가져옵니다.
다중 배열 순회하는 법:
중첩 for으로 하는 법.
for i := 0; i < len(matrix); i++ {
for j := 0; j < len(matrix[i]); j++ {
fmt.Println(matrix[i][j])
}
}
range로 하는 법:
for _, row := range matrix {
for _, value := range row {
fmt.Println(value)
}
}
재귀 함수를 사용할 수도 있다.
func traverse(matrix [][]int, row int) {
if row == len(matrix) {
return
}
for _, value := range matrix[row] {
fmt.Println(value)
}
traverse(matrix, row+1)
}
// 함수 호출
traverse(matrix, 0)
반복자 패턴을 사용할 수도 있다.
type Iterator struct {
matrix [][]int
row int
col int
}
func (it *Iterator) Next() (int, bool) {
if it.row >= len(it.matrix) {
return 0, false
}
value := it.matrix[it.row][it.col]
it.col++
if it.col >= len(it.matrix[it.row]) {
it.col = 0
it.row++
}
return value, true
}
// 반복자 사용
iterator := Iterator{matrix: matrix}
for value, ok := iterator.Next(); ok; value, ok = iterator.Next() {
fmt.Println(value)
}
라이브러리 함수가 있으면 그걸 사용할 수도 있다.
채널을 이용한 순회도 가능하다.
ch := make(chan int)
go func() {
for _, row := range matrix {
for _, value := range row {
ch <- value
}
}
close(ch)
}()
for value := range ch {
fmt.Println(value)
}
재귀랑 채널까지는 하나도 모르겠다...
배열 크기는 위에 한 번 언급했으니 여기서 마무리한다.
Tucker의 Go 언어 프로그래밍 13장, 구조체 갑시다!