Sad Puppy 3 [묘공단] Go언어 기초 12-14장(배열, 구조체, 포인터) :: 개발자 아지트

해당 글은 MacOS(M1) 환경을 기준으로 작성되었음. 

배열

C에서 사용하는 배열개념이랑 같다. 

배열에 담을 수 있는 요소는 처음에 초기화 한후에 고정됨(파이썬 리스트로 구현한 배열이랑 다름)

 

Go에서 배열 선언

var 변수명 [요소 개수]타입

// 5개의 float64타입의 원소를 가진 t라는 이름을 가진 배열 선언
var t [5]int

 

 

이렇게 선언만 하고 별도의 초깃값을 짖어하지 않으면 각 요솟값은 int 타입의 기본값 0으로 초기화 됨 

 

예제 작성전에 참고해야 할 것 

 

더보기

Go 1.16 버전부터 Go 모듈 사용이 기본이 됐음. 

 

Go 1.16 버전 이전에는 Go 모듈을 만들지 않는 코드는 모두 $GOPATH/src 폴더 밑에 있어야 했지만,

Go 모듈 사용이 기본이 되면서 모든 Go코드는 Go 모듈 밑에 있어야 한다. 

 

따라서, 모든 예제를 빌드하기 전에 $go mod init 을 통해 Go 모듈을 만들어 줘야함. 

 

아래의 명령어를 통해 대상 파일(폴더)에 대한 Go 모듈을 만들어준다. 

$go mod init [파일위치/파일명]

 

아래의 명령어를 통해 실행 파일을 만들어준다.

$go build 

 

파일을 실행한다. 

$./[파일명]

 

직접적인 사용 방법 예시

 

1. 배열을 반복문을 통해서 출력하기 

package main

import "fmt"

func main() {
	var t [5]float64 = [5]float64{23.0, 25.9, 22.8, 24.9, 21.2}

	for i := 0; i < 5; i++ {
		fmt.Println(t[i])
	}
}

 

 

배열 변수 선언 및 초기화 

var nums [5]int

 

별도 초기값을 정하지 않음으로, 요소값은 int타입의 기본값인 0으로 초기화됨 

 

month := [3]string{"jan", "feb", "mar"}

 

 

배열의 일부만 초기화 할 경우, 나머지 값들은 해당 타입의 기본값으로 초기화됨

var temps [5]float64{11.1, 22.2}

 

 

배열 요소 개수 생략하는 방법은 다음과 같음

 

x := [...]int{10, 20, 30}

 

주의할점. 배열을 선언할 때 개수는 항상 상수를 사용해야함. 

 

배열은 인덱스를 통해 읽고 쓸 수 있음

 

range를 통한 배열 순회

package main

import "fmt"

func main() {
	var t [5]float64 = [5]float64{23.0, 25.9, 22.8, 24.9, 21.2}

// i는 인덱스 v는 값. 
// 만약 인덱스가 필요없는 경우 _를 이용해 인덱스를 무효화 할 수 있음
	for i , v := range t{
		fmt.Println(i, v)
	}
}

 

 

배열의 요소는 연속된 메모리로 되어있음 (차원이 중첩되어도 마찬가지임)

 

요소 위치 = 배열 시작 주소 + (인덱스 * 타입 크기)

 

 

배열 크기 = 타입크기 * 항목 개수

 

구조체

  • 구조체 선언
  • 구조체 변수 사용
  • 구조체 특징
type  타입명  struct {
	필드명 타입 
    ...
    필드명 타입
}

// type 키워드를 사용함으로서 새 사용자 정의 타입을 정의함 
// 타입의 종류 struct 
// 중괄호{ } 안에 해당 구조체에 속한 필드들을 적어줌 
// 각 필드는 필드명과 타입을 기입

 

예시

type Sandwitch struct {
	Bread string
    Source string
    Price  int
}

 

Sandwitch 타입 구조체 변수 선언 

 

var egg Sandwitch

 

 

각 필드에 대한 접근은 . 연산자를 통해 접근한다. 

 

package main

type Sandwitch struct {
	Price  float64
	Bread  string
	Source string
	Cnt    int
}

func main() {
	var eggSandwitch Sandwitch
	eggSandwitch.Bread = "Wheat"
	eggSandwitch.Price = 6400.1
	eggSandwitch.Source = "salt"
	eggSandwitch.Cnt = 1
}

 

구조체 변수 초기화 

 

초깃값을 생략하면 모든 필드가 기본값으로 초기화 됨

 

다음과 같이 모든 필드를 순서대로 초기화 할 수 있음

 

var sandwitch Sandwitch = Sandwitch{ 100.0, "flat", "salt", 1 }

 

만약 필드 일부만 초기화할 경우, 초기화 되지 않은 나머지 변수에는 기본값으로 초기화 됨

 

구조체를 포함하는 구조체

 

type Sandwitch struct {
	Price  float64
	Bread  string
	Source string
	Cnt    int
}

type VIPSandwitch struct {
	SandwitchInfo Sandwitch
	Gifts string
}

 

 

포함된 필드 이용시 . 하나를 통해 접근이 가능함

package main

import "fmt"

type Sandwitch struct {
	Price  float64
	Bread  string
	Source string
	Cnt    int
}

type VIPSandwitch struct {
	Sandwitch
	Gifts string
    Price float64 // 필드가 중복되는 경우 호출할 때, 
}

func main() {
	sandwitch := Sandwitch{6400.1, "Wheat", "salt", 1}
	vip := VIPSandwitch{
		Sandwitch {100.0, "flat", "oliveOil", 2},
		"Cookie",
	}

	fmt.Printf("샌드위치 %s", sandwitch.Bread)
	fmt.Printf("샌드위치 %s", vip.Bread)
    fmt.Printf("샌드위치 가격: %d", vip.Sandwitch.Price)
    fmt.Printf("vip 샌드위치 가격: %d", vip.Price)
    

}

 

구조체 크기

구조체 변수가 선언되면, 컴퓨터는 구조체 필드를 모두 담을 수 있는 구조체의 메모리 공간을 할당함

 

type Student struct{
	Age int
    Score float64
}

 

 

만약 위와같은 구조체 Student가 정의 되어 있다고 했을때, 

 

var student Student

 

 

int 타입 Age는 8바이트 , float타입 Score는 8바이트로 총 16바이트의 크기가 필요함

student는 16바이트의 크기를 가지는 구조체임 

 

구조체의 객체는 대입연산자만으로도 필드값을 복사할 수 있음

Go 내부에서는 필드 각각이 아닌 구조체 전체 필드가 한 번에 복사됨.

 

메모리 정렬

 

메모리 정렬은 데이터에 접근할 때, 접근 효율을 높이기 위해 메모리를 일정 크기로 정렬하는 것을 말한다. 

 

레지스터는 실제 연산에 사용되는 데이터가 저장되는 곳인데, 레지스터 크기가 4바이트면 32비트 컴퓨터고,

레지스터 크기가 8바이트면 64비트 컴퓨터이다. 

 

레지스터 크기가 8바이트란 말은, 연산 한번에 8바이트 크기를 연산할 수 있다는 것이다. 

따라서 데이터가 레지스터 크기과 같은 크기로 정렬돼 있으면, 데이터를 읽어올 때 효율적이다. 

 

만약 

type Student struct {
	Age int32 // 4바이트 
    Score float64 // 8바이트
}

var student Student

 

인 경우, 

student의 메모리 주소가 240번지이면, Age의 시작 주소 또한 240이 된다. 

이때 Age의 바로 뒤에 Score의 메모리 주소를 할당하면 시작은 244가 된다. 

244는 8의 배수가 아니므로 메모리 접근 시에 효율이 떨어진다. 

 

이 때문에, 프로그램 언어에서 Student 구조체 할당 시, Age와 Score을 4바이트 띄워서 할당한다. 

그러면 Score의 메모리 주소 시작은 248이 된다. 

 

이런 방법을 메모리 패딩이라고 한다. 

 

메모리 낭비를 줄이기 위해서는 8바이트 보다 작은 필드는 8바이트 크기를 고려해서 몰아서 배치하는 것이 효율적이다. 

 

메모리 공간이 작인 임베디드의 프로그램이라면 패딩을 고려하는게 좋다. 

 

프로그램에서 구조체는 관련 데이터를 묶어 응집도를 높이고 재사용성을 증가시킨다. 

 

*결합도는 모듈간 상호 의존 관계의 높은 정도를 말하고, 응집도는 모듈의 완성도를 말하며 모듈 내부에 모든 기능이 하나의 목적에 맞게 충실하게 모여있는지를 나타내는 용어이다. 

 

 

포인터

  • 포인터 정의
  • 포인터 사용법
  • 인스턴스 개념

 

포인터는 메모리주소를 값으로 갖는 타입이다.

float 타입 변수 b가 있을때, b는 메모리에 저장되어있고, 메모리 주소는 속성으로 갖는다. 

그리고 변수 b의 주소가 0x0010번지일 때, 이 주소값은 숫자다. 

이 값은 다른 변수의 값으로 사용될 수 있다.  남의 변수 주소를  값으로 가질 수 있는 변수를 포인터 변수라고 한다. 

 

포인터 변수는 주소값만 값으로 가질 수 있는 변수다.

 

p = &b 

// p 포인터 변수는 변수 b의 주소값을 값으로 갖도록 대입하고 있다. 
// 이런 상태를 포인터 변수 p가 변수 b를 가리킨다고 말할 수 있다. 
// 메모리 주소를 값으로 가지고, 메모리 주소 값을 통해 메모리 공간을 가리키는 타입을 포인터 타입이라고 한다.

 

 

포인터는 여러 포인터 변수가 하나의 메모리 공간을 가리킬 수 있다. 

포인터가 가리키는 (메모리 주소가 가리키는) 공간에 있는 값을 읽거나 수정할 수 있다. 

 

포인터 변수 선언

 

var p *int

// 위와 같이 포인터 변수는 가리키는 데이터 타입 앞에 *을 붙여 선언한다.

var p2 *Student
// Student 구조체를 가리키는 경우 포인터 변수 선언 방법은 위와 같다.

 

 

포인터 변수에 값을 넣기 위해서는?

 

var b int
var p *int
p = &b

// 3번 라인은 b의 메모리 주소를 p 포인터 변수에 대입하는 것이다.

 

포인터 변수 p를 통해 b의 값을 변경하는 방법은?

 

*p = 100

 

 

// 포인터 변수 값을 출력하기 위해서는 ?

fmt.Printf("나는 포인터 변수 P이며, 값은 %p", p)

// 포인터 변수가 가리키는 메모리 공간의 값을 출력하기 위해서는 ?

fmt.Printf("나는 포인터 변수 P이며, 내가 가리키는 메모리 공간의 값은? %d", *p)

 

 

포인터 변수값 비교

 

포인터가 같은 메모리 공간을 가리키는지 확인하기 위해서 == 연산자를 사용할 수 있다. 

 

만약 같으면 true를, 같지 않으면 false를 출력한다. 

 

포인터의 기본값 nil

 

포인터 변수값을 초기화하지 않으면 기본값은 nil이다. 

nil은 따지고보면, 0이다. 그러나 정확한 의미는 어떠한 메모리 공간(주소)도 가리키고 있지 않다는 의미이다. 

 

var p *int
if p !=nil {
	// 포인터가 유효한 메모리 공간을 가리키고 있지 않아요!
}

 

 

포인터를 쓰는 이유

 

변수를 대입하거나, 함수에 인자를 통해 값을 전달할 때는 항상 값을 복사하여 활용한다. 

값을 복사한다는 의미는 메모리가 이중으로 사용된다는 의미이다. 

즉, 메모리가 필요 이상으로 많이 쓰인다는 이야기이다. 또 큰 메모리 공간을 복사할 경우 성능 문제가 발생할 수 있다. 

추가로 기존 값의 변경을 원할경우, 값이 복사되어 활용되므로, 변경 사항을 반영할 수 없다. 

 

다음과 같은 상황은 포인터를 메모리 낭비 없이 적절하게 사용하는 사용하는 경우이다. 

 

package main 

import "fmt"

type Data struct {
	value int
    data [200]int
}

func ChangeData(arg *Data) {
	arg.value = 100
    arg.data[100] = 100
}

func main(){
	var data Data
    
    ChangeData(&data) // 이때, 데이터 변수 값이 아닌 변수의 메모리 주소만 인수로 전달된다. 
    // 메모리 주소는 8바이트 숫자 값이다. 따라서 8바이트만 복사된다. 
    fmt.Printf("value = %d\n", data.value)
    fmt.Printf("data[100] = %d\n", data.data[100])
}

// 출력 결과 
// value = 999
// data[100] = 999

 

 

Data 구조체를 생성해 포인터 변수 초기화해보기 

 

구조체 변수를 따로 생성하여 포인터 변수에 구조체 변수를 가리키게 하는 방법

 

var data Data
var p *Data = &data

 

 

구조체 변수를 따로 생성하지 않고, 바로 포인터 변수에 구조체를 생성해 주소를 초기값으로 대입하는 방법은 다음과 같다. 

 

var p *Data = &Data{} // Data구조체를 생성해 초기화하는 방식
// &Data{} 는 데이터 구조체를 만들어 주소를 반환하는 것이다.

 

이 부분에서 이해가 안되겠지만, 일단 인스턴스로 넘어가길 권장한다. 

 

인스턴스

 

인스턴스는 메모리에 할당된 데이터의 실체이다.

var data Data

 

위는 Data 타입 값을 저장할 수 있는 메모리 공간(data)을 할당한다. 

 

이 상태에서 데이터 타입의 포인터 변수를 성넌하여 data 변수의 주소 값을 대입시키면 다음과 같다. 

var data Data
var p *Data = &data

 

 

이를 통해 p가 생성될 때, 새로운 Data의 인스턴스가 생성됐다고 보지 않고 기존의 data 인스턴스를 가리킨다고 본다. 

즉, 위 코드에서 Data 인스턴스의 총 개수는 한 개이다.

 

 

인스턴스를 따로 생성하지 않고, 바로 인스턴스 생성과 동시에 그 주소를 포인터 변수에 초기 값으로 대입하는 코드는 다음과 같다. 

 

var p *Data = &Data{}

 

이러면 Data인스턴스가 만들어지고, 해당 주소를 포인터 변수 p가 가리키게 된다. 

 

 

만약 다음과 같은 경우, 

 

var p1 *Data = &Data{}
var p2 *Data = p1
var p3 *Data = p1

 

포인터 변수가 아무리 많아도, p2, p3도 p1의 포인터변수 값을 갖는다.

따라서 p1, p2, p3 모두 공통된 인스턴스를 가리키게 된다. 즉, 포인터 변수가 많다 해도 인스턴스가 추가로 생성되지 않는다. 

 

 

인스턴스는 데이터의 실체

 

포인터를 이용해 인스턴스에 접근할 수 있다. 

구조체 포인터를 함수 매개변수로 받는다는 것은 구조체 인스턴스로 입력을 받겠다는 것이다. 

 

new() 내장 함수

 

포인터 값에 대해 따로 변수 선언을 하지 않고, 초기화 하는 또 다른 방법이 있다. 

 

p1 := &Data{} // 기존의 &를 사용한 초기화 
var p2 = new(Data) // new를 통한 초기화

 

 

인스턴스가 사라지는 타이밍

 

메모리는 한정되어 있으므로, 데이터가 할당되면 적절한 타이밍에 사라져야 프로그램이 계속 동작할 수 있다. 

Go에서는 데이터 할당 후 적절한 타이밍에 사라지게 하는 기능이 있다. 이를 가비지 컬렉터(메모리 청소부)라고 한다. 

이 기능이 일정 주기로 메모리에서 필요없는 데이터를 정리한다. 

 

Go에서는 아무도 찾지 않는 데이터를 사용하지 않는 데이터라고 인식하여 처리한다. 

 

예를들면 다음과 같은 상황이 있다. 

 

func TmpFunc(){
	s := &Student{} // s포인터 변수가 선언되고, Student인스턴스도 생성된다. 
    s.Age = 1
    fmt.Println(u)
}

// 이후 s는 사라지고, Student 인스턴스도 사라진다.

 

 

메모리는 엄청 크기 때문에, 가비지 컬렉터가 한번 움직일때는 큰 메모리를 전부 검사해서 쓸모 없어진 데이터를 정리하는데 성능이 많이 필요하다. 따라서, 가비지 컬렉터 사용시 메모리 관리는 잘 될지 몰라도 성능은 떨어질 수 있다. 

 

Go에서의 스택 메모리 힙 메모리 

 

대체로 프로그래밍 언어에서 메모리를 할당 할 때, 스택 메모리 영역이나 힙 메모리 영역을 사용한다. 

효율적인 면에서는 스택 메모리 영역이 힙 메모리 영역보다 우세하다. 

스택 메모리 영역에서 메모리를 할당할 경우, 스택 메모리는 함수 내부에서만 사용가능하다. 

 

C/C++에서는 malloc()함수를 직접 호출해 힙 메모리 공간을 할당하고,

Java에서는 기본 타입은 스택에, 클래스 타입은 힙에 할당한다. 

 

 

Go의 경우, 탈출 검사를 통해 어느 메모리에 할당할지 결정한다. 

함수 외부로 노출되는 인스턴스는 함수가 종료돼도 사라지지 않는다. 

함수 외부로 노출되는지 마는지의 여부는 Go에서 자동으로 검사해서 스택에 할당할지 힙에 할당할지 결정한다. 

 

Go에서 스택 메모리는 계속 증가되는 동적 메모리 풀이다. 

C/C++ 처럼 일정 크기를 갖지지 않아서 메모리 효율이 좋고, 재귀 호출로 인해 스택 메모리가 고갈되거나 하는 문제도 발생하지 않는다. 

 

 

 

 

 

해당 글은 [Tucker의 Go 언어 프로그래밍] 12장~14장을 읽고 공부한 글입니다. 

 

+ Recent posts