[Go] 데이터 (Data)

김무연·2025년 2월 5일

Go에는 메모리를 할당하는 두 가지 기본 방식이 있는데, 빌트인 함수인 new와 make이다. 서로 다른 일을 하고 다른 타입들에 적용되기 때문에 혼란스러울 수 있지만, 규칙은 간단하다.

new를 사용하는 메모리 할당

내장 함수로 메모리를 할당하지만 다른 언어에 존재하는 같은 이름의 기능과는 다르게 메모리를 초기화하지 않고, 단지 값을 제로화 한다. new(T)는 타입 T의 새로운 객체에 제로값이 저장된 공간(zeroed storage)을 할당하고, 그 객체의 주소인 *T 를 반환한다. 새로 제로값으로 할당된 타입 T를 가리키는 포인터를 반환하는 것이다.

package datatest

import "fmt"

type Ttype struct {
	arg1 int
	arg2 int
}

func Datatest() {
	result := new(Ttype)
	var result2 Ttype 

	fmt.Println(result, "1")
	fmt.Println(result2, "2")

}

//
&{0 0} 1
{0 0} 2

new가 반환하는 메모리가 제로값으로 셋팅되어 있어 사용자는 자료 구조로 사용할 때 바로 작업을 진행할 수 있다.

생성자와 합성 리터럴

따로 제로값만으로는 충분치 않고 생성자(constructor)로 초기화해야 할 필요가 생긴다. 예시를 보자면

func NewFile(fd int, name string) *File {
    if fd < 0 {
        return nil
    }
    f := new(File)
    f.fd = fd
    f.name = name
    f.dirinfo = nil
    f.nepipe = 0
    return f
}

이 코드에는 불필요하게 반복된 코드들이 존재한다. 이를 합성 리터럴로 간소화 할 수 있는데, 이 표현이 실행될 때마다 새로운 인스턴스를 만들어 낸다.

func NewFile(fd int, name string) *File {
   if fd < 0 {
       return nil
   }
   f := File{fd, name, nil, 0}
   return &f
}

go는 로컬 변수의 주소를 반환해도 아무 문제가 없다. 또한 특이하게도 변수에 연결된 저장공간은 함수가 return 하더라도 살아 남는다. 실제로, 합성 리터럴의 주소를 취하는 표현은 매번 실행될 때마다 새로운 인스턴스에 연결된다. 그러므로 마지막 두줄을 묶어 버릴 수 있다.

    f := File{fd, name, nil, 0}
   return &f
   
   
   // 변경
   return &File{fd, name, nil, 0}

합성 리터럴의 필드들은 순서대로 배열되고, 반드시 입력되어야 한다. 하지만 요소들에게 값을 넣어 필드:값 형식으로 명시적으로 짝을 만들면, 초기화는 순서에 관계없이 나타낼 수 있다. 입력되지 않은 요소들은 제로값을 갖게 된다.

retrun &File{fd : fd}

// 나머지 field들은 제로값을 가지게 된다. 

제한적인 경우로, 만약 합성 리터럴이 전혀 필드를 갖지 않을 때는, 그 타입의 제로값을 생성한다. 아래의 둘은 동일한 표현이다.

	test1 := new(Ttype)
	test2 := &Ttype{}

	fmt.Println(test1, test2)
   //
   &{0 false} &{0 false}

또 합성 리터럴은 arrays, slices, maps를 생성하는데 사용될 수도 있다. 필드 레이블로 인덱스와 맵의 키를 적절히 사용해야 한다.

a := [...]string   {arg1: "no error", arg2: "Eio", arg3: "invalid argument"}
s := []string      {arg1: "no error", arg2: "Eio", arg3: "invalid argument"}
m := map[int]string{arg1: "no error", arg2: "Eio", arg3: "invalid argument"}

//
arg1, arg2, arg3의 값에 상관없이 서로 다르기만 하면 초기화가 작동한다.

make를 사용하는 메모리 할당

내장 함수의 make(T, args)는 new(T) 와는 다른 목적의 서비스를 제공한다. slices, maps, channels에만 사용하고 (*T가 아닌) 타입 T의 (제로값이 아닌) 초기화된 값을 반환한다.

이유는 위의 세 타입이 내부적으로 반드시 사용되기 전 초기화 되어야 하는 데이터 구조를 가리키고 이씩 때문이다. slice는 세가지 항목의 기술항으로 '데이터를 가리키는 포인터', '크기', '용량'을 가지며, 이 항목들이 초기화되기 전 까지, slice는 nil 이다.

make([]int, 10, 100)

메모리에 용량이 100인 int 배열을 할당하고, 그 배열의 처음 10개를 가리키는, 크기 10, 용량이 100인 slice 데이터 구조를 생성한다. 여기서 용량을 생략하고 make([]int, 10) 이렇게 써주어도 된다.

그에 반해, new([int]) 는 새로 할당되고, 제로값으로 채워진 slice 구조를 가리키는 포인터를 반환하는데, 즉 초기화 되지 않았으니 nil slice 값의 포인터인 것이다.

package maketest

import "fmt"

func Maketest() {
	// 아래는 안된다. 왜냐하면 new는 포인터를 반환하기 때문에 타입이 *[]int가 되어야 한다.
	// var p []int = new([]int)
	var p *[]int = new([]int)
	// 아래는 용량 11중에서 10개만 나타내는 것, 뒤의 용량은 생략이 가능하다
	var v []int = make([]int, 10, 11)
    // var v []int = make([]int, 10)

	fmt.Println(p)
	fmt.Println(v)
    
    ////
    [6]int&[]
	[0 0 0 0 0 0 0 0 0 0]
}

배열

배열은 메모리의 레이아웃을 상세하게 계획하는데 유용하며, 때로는 메모리 할당을 피하는데 도움이 된다. 하지만 주로 slice의 재료로 쓰인다.

배열의 특징으로는

  1. 배열은 값이다. 한 배열을 다른 배열에 할당할 때 모든 요소가 복사된다.
  2. 특히, 함수에 배열을 패스할 때, 함수는 포인터가 아닌 복사된 배열을 받는다.
  3. 배열의 크기는 타입의 한 부분이다. [10]int 와 [20]int는 서로 다른 타입이다.
  4. slice와는 다르게 배열은 고정된 크기를 지정해주어야 한다. 하지만 [...] 을 이용하면 배열의 크기를 자동으로 결정한다.

허나 배열을 값으로 사용하는 것이 유용할 수도 있지만 또한 비용이 큰 연산이 될 수도 있다. 굳이 메모리 사용을 효율적으로 다루기 위해 포인터로 전달이 가능하기는 하다

// 배열을 값으로 전달
func Sum(a [3]float64) (sum float64) {
    for i, v := range a {
        sum += v
        fmt.Println("I : ", i, " v : ", v)
    }
    return sum
}

// 포인터로 배열을 전달
func Sum(a *[3]float64) (sum float64) {
    for i, v := range *a {
        sum += v
        fmt.Println("I : ", i, " v : ", v)
    }
    return sum
}
  1. 배열을 값으로 전달할시, 배열의 모든 요소가 복사되어 함수 내부로 전달된다. 따라서 배열의 크기가 커지면 메모리 사용량이 증가할 수 있다.

  2. 포인터로 배열을 전달할 시, 배열의 주소를 전달해 원본 배열을 참조하기 때문에 복사가 일어나지 않는다. 따라서 메모리 사용이 효율적이 될 수 있다.

하지만 이런 스타일은 Go언어스럽지 못하다고 한다. 대신 Slice를 이용하도록 권장한다.

Slices

Slices는 배열을 포장하므로써 데이터 시퀀스에 더 일반적이고, 강력하며, 편리한 인터페이스를 제공한다. 변환 행렬처럼 뚜렷한 차원을 가지고 있는 항목들을 제외하고, Go에서는 거의 모든 배열 프로그래밍은 Slice를 사용한다고 한다.

Slice는 값의 복사가 아닌 내부의 배열을 가리키는 레퍼런스를 쥐고 있어, 만약에 다른 slice에 할당되어도, 둘 다 같은 배열을 가리킨다. 함수가 slice를 받아 그 요소에 변화를 주면 호출자 (caller)에게도 보이게 된다. 배열의 포인터를 넘긴 효과와 같은 효과이다. ( 배열을 넘기게 되면 값을 복사 하기 때문에 변화가 보이지 않는다 )

func ( f*File) Read(buf []byte) (n int, err error)

위 함수는 포인터와 count를 인자로 받기 보다는 슬라이스를 인자로 받는다. 슬라이스가 데이터를 읽을 수 있는 상한을 갖고있기 때문이다.

slice의 길이는 내부배열의 한계내에서는 얼마든지 바뀔 수 있다. 그냥 slice 자체에 할당하면 된다.
slice의 용량은 내장함수 cap을 통해 얻을 수 있는데, slice가 가질 수 있는 최대 크기를 보고한다.

	a := []int{1,2,3,4,5}
	b := []int{1,2,3}
	fmt.Println(cap(a), cap(b))
    
    ////
    5 3

여기서 append(a, 6)처럼 새로운 값을 추가하게 되면, 새로운 메모리에 값이 할당되게 되고, 기존 slice는 유지되며 반환된다.

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

	c := append(a, 6)
	fmt.Println(c, a)
    
    ////
    [1 2 3 4 5 6] [1 2 3 4 5]

이차원 slice

type dimension [3][3]int
type test [][]byte

때로 이차원의 slice를 메모리에 할당할 필요가 생기면, 두 가지 방식이 가능하다.

  1. 각 slice를 독립적으로 할당
    • 객체를 생성하기 위해 메모리 할당을 한 번에 하기
// 최상위 레벨 slice 할당
pict := make([][]uint8, YSize) // 유닛 y 마다 한 줄씩
//  각 줄을 반복하며 할당
for i := range pict {
	pict[i] = make([]uint8, XSize)
 }
  1. slice를 하나 할당하고 각 slice로 자른 후 포인터를 주는 방식
    • slice가 자라거나 줄어들 시, 다음 줄을 덮어쓰는 일을 방지해야 할 때
// 최상위 레벨 배열 할당
pict := make([][]uint8, YSize)
// 모든 값을 담을 수 있는 큰 slice 할당
pixel := make([]uint8, XSize * YSize) 
// 각 줄을 반복하면서, 남겨진 pixel slice의 처음 부터 크기대로 슬라이싱
for i := range pict {
	picture[i], pixel = pixel[:XSize], pixel[XSize:]
}

Maps

Map은 강력한 데이터 구조로 한 타입의 값들(key)를 다른 타입의 값(value)에 연결해준다. js의 객체 또는 pythcon의 dictonary와 유사한 key-value 형태를 가지고 있다.


test := map[string]int {
	"q" : 3,
    "w" : 2,
    }

다만 특이한 점이라면 강력한 강타입이기 때문에 key의 type과 value의 타입 모두 지정해주어야 한다. map[key타입]value타입, 또하나 특이한 점은 만약 없는 key를 호출할 경우 value타입의 제로값이 표출이 된다.

	a := map[string]int {
		"q" : 3,
		"w" : 2,
	}

	fmt.Println(a, a["q"], a["e"])
    
    ////
    map[q:3 w:2] 3 0

때로는 부재값과 제로값을 구분할 필요도 있다. 특정값이 있는건지, 혹은 map이 전혀 아니기 때문에 값이 0은 아닌건지 등.. 그것은 복수 할당의 형태로 구별할 수 있다.


var seconds int
var ok bool

seconds, ok = timeZone[tz]

이것을 "comma ok" 관용구라고 부른다. 위 예제에서 만약 tz가 있다면, seconds는 적절히 세팅될 것이고 ok는 true가 된다. 반면 없다면 seconds는 제로값이 될 것이고 ok는 false가 될 것이다. 아래는 대략적인 에러 보고이다.

func offset(tz string) int {
	timeZone := map[string]int {
		"UTC":  0*60*60,
		"EST": -5*60*60,
		"CST": -6*60*60,
		"MST": -7*60*60,
		"PST": -8*60*60,		
	}

	if seconds, ok := timeZone[tz]; ok {
		return seconds
	}
	log.Println("unknwon time Zone", tz)

	return 0
}

실제 값에 상관없이 map내 존재 여부만 검사하려면, _ (언더바) 즉 공백 식별자를 변수가 있어야 할 자리에 놓으면 된다.

_, present := timeZone[tz]

Map의 엔트리를 제거하기 위해서는, 내장 함수 delete 를 이용, map과 제거할 key를 인수로 쓴다. map 에 key가 이미 부재하는 경우에도 안전하게 사용된다.

test := map[string]int {
		"Q" : 3,
		"w" : 3,
	}

fmt.Println(test)
delete(test, "Q")
fmt.Println(test)
delete(test, "Q")
fmt.Println(test)
delete(test, "w")
fmt.Println(test)

////
map[Q:3 w:3]
map[w:3]
map[w:3]
map[]

slice에 slice append 하기

호출 지점에서 ...를 이용한다.

	x := []int{1,2,3}
	y := []int{4,5,6}

	x = append(x, y...)

여기서 오는 ... 은 컴파일러에게 앞에 있는 변수를 인수 리스트로 취급하라는 뜻이다.

(eclipse triple dot) 삼 도트 이용방법

  1. 함수의 인자에 가변 인자로 선언하는 경우 (인자 값을 2,3개로 자유롭게 넘겨줄 수 있다
func sum(nums ...int) int {
	res := 0
	for _, n := range nums {
		res += n
	}
	return res
}

func Example_가변인자_함수() {
	total1 := sum(1, 2, 3)
	total2 := sum(1, 2)
	fmt.Println(total1)
	fmt.Println(total2)

	//Output:
	//6
	//3
}
  1. 가변 인자를 인자로 받는 함수에 slice를 넘겨주는 경우
func Example_가변인자_함수에_전달하기() {
	numList := []int{2, 3, 5, 6}
	fmt.Println(sum(numList[0], numList[1], numList[2], numList[3]))
	//...표기법을 통해서 가변인자에 unpack해서 전달할 수 있다
	fmt.Println(sum(numList...))

	//Output:
	//16
	//16
}

위에서 sum 함수의 인자는 가변인자로 선언 되어있기 때문에 sum(1,2,3)으로 념겨줘야 하는데, 슬라이스로 선언된 컬렉션을 하나하나 입력하기는 불편하다. 그래서 Go에서는 slice 뒤에 ... 을 붙이면 가변인자에 unpacking 해서 전달해준다.

  1. 배열 리터럴에서 길이를 지정하는 경우
func Example_array_literal() {
	//배열 리터럴에서 ... 표기법은 리터럴의 요소 수와 동일한 길이를 지정한다
	strList := [...]string{"Frank", "Joe", "Angela"}
	fmt.Println(len(strList))

	//Output: 3
}

배열 리터럴에서 ... 표기법을 사용하면 리터럴의 요소 수와 동일한 길이 지정해준다.

  1. Go 명령어 wildcard로 사용하는 경우
# 패키지 목록을 지정할 때 ...표기법은 패키지 목록을 wildcard로 사용된다
$ go test ./...

Go 명령에서 ... 표기법은 패키지 목록을 wildcard (다른곳에서 *과 같은것) 로 사용하겠다는 의미이다. 위 명령어는 현재 폴더에서 모든 폴더에 있는 테스트 파일을 실행하라는 뜻이다.

profile
Notion에 정리된 공부한 글을 옮겨오는 중입니다... (진행중)

0개의 댓글