Golang 문법 - 변수 (Variable)

Johnny·2021년 3월 29일
3

Saturday Night 스터디

목록 보기
1/8

📖 이 글은 Saturday Night 스터디에서 The Ultimate Go를 주제로 발표하기 위해 만들어졌습니다.


빌트인 타입 (Built-in Type)

Go언어에서 타입은 두 가지의 질문을 통해 완전성과 가독성을 제공합니다.

할당한 메모리의 크기는 얼마인가? (예: 32-bit, 64-bit)
이 메모리 데이터는 무엇을 의미하는가? (예: int, uint, bool, ...)

타입은 int32, int64처럼 명확한 이름도 있습니다.

uint8은 1 byte 메모리 크기에 10진수 숫자를 가지고 int32는 4 byte 메모리 크기에 10진수 숫자를 가집니다.
uint나 int처럼 메모리 크기가 명확하지 않은 타입도 존재하는데, 이를 선언하면 빌드 시 프로그램이 돌아갈 아키텍처에 따라 크기가 결정됩니다.

32-bit 운영체제: int32, unint32
64-bit 운영체제: int64, unint64

  • Go언어에서 지원하는 크로스 컴파일 종류

Go언어에서 지원하는 크로스 컴파일 종류

출처: https://dev93.tistory.com/7



typedescriptionbyte
booltrue, false1
uint80 ~ 2551
uint160 ~ 65,5352
uint320 ~ 4,294,967,2954
uint640 ~ 18,446,744,073,709,551,6158
int8-128 ~ 1271
int16-32,768 ~ 32,7672
int32-2,147,483,647 ~ 2,147,483,6474
int64-9,223,372,036,854,775,808 ~ 9,223,372,036,854,775,8078
byteuint8의 alias1
runeint32의 alias
(Go언어는 char type이 존재하지 않으며 이를 rune이라는 개념으로 문자의 코드 값을 표현합니다.)
4
uint하드웨어 아키텍처에 따라 uint32 or uint644, 8
int하드웨어 아키텍처에 따라 int32 또는 int644, 8
uintptr포인터 값을 저장하기 위한 unsigned int4, 8
float3232-bit 부동 소수점 숫자4
float6464-bit 부동 소수점 숫자8
complex64float32 실수부와 float32 허수부로 구성8
complex128float64실수부와 float64 허수부로 구성16
string배열을 가리키는 포인터와 배열의 길이 두 값으로 구성8, 16


워드 크기 (Word)

오늘 날 컴퓨터는 32-bit와 64-bit 아키텍쳐로 나뉩니다. 이는 워드와 연관이 있는데요.

컴퓨터 구조에서 워드란 연산을 통해 저장 장치로부터 프로세서의 레지스터에 옮겨놓을 수 있는 데이터 단위입니다. 예전에 사용해왔던 컴퓨터 구조에서는 한 워드에 4 byte 길이, 즉 32-bit였습니다. 현재는 주로 64-bit(8 byte) 구조의 프로세서들을 사용하고 있습니다.

한 워드에는 하나의 데이터를 저장할 수 있는데, 워드의 크기란 워드가 몇 byte인지를 의미하며 메모리 주소의 크기와 같습니다.

예를 들어 64-bit 아키텍처에서 워드 사이즈는 64-bit (8 byte)이고, 메모리 주소의 크기도 64-bit 입니다. 따라서 int는 64-bit입니다.



문자열 (String)


출처: https://research.swtch.com/godata#Strings

string의 구조는 두 개의 워드로 이루어져있습니다. 첫번째 워드는 배열로 구성된 값의 첫 인덱스를 가리키는 포인터이고, 두번째 워드는 배열의 길이입니다. string의 값은 배열로 메모리 영역 어딘가에 저장되어 있습니다.

그래서 string이 짧건 길건 변수의 사이즈는 동일합니다. (예제 코드)

package main

import (
    "fmt"
    "runtime"
    "unsafe"
)

func main() {
    fmt.Println(runtime.GOARCH)
    fmt.Println(unsafe.Sizeof("hi"))
    fmt.Println(unsafe.Sizeof("Hello"))
}
[OUTPUT]
amd64  // 64-bit 아키텍처 기반이므로 하나의 워드는 8byte
16     // string은 2개의 워드로 이루어져있으므로 16byte
16


제로값 개념 (Zero Value)

모든 변수는 초기화되어야 합니다. 어떤 값으로 초기화 할 지를 명시하지 않으면 제로값으로 초기화됩니다.

할당하는 메모리의 모든 bit는 0으로 리셋됩니다.

TypeZero Value
Booleanfalse
Integer0
Floating Point0
Complex0i
String""
Pointernil


선언과 초기화 (Declare & Initialize)

var 키워드로 변수를 선언하면 타입의 제로값으로 초기화됩니다. (예제 코드)

package main

import "fmt"

func main() {
    var a int
    var b string
    var c float64
    var d bool

    fmt.Printf("var a int \t %T [%v] \n", a, a)
    fmt.Printf("var b string \t %T [%v] \n", b, b)
    fmt.Printf("var c float64 \t %T [%v] \n", c, c)
    fmt.Printf("var d bool \t %T [%v] \n", d, d)
}
[OUTPUT]
var a int        int [0] 
var b string     string [] 
var c float64    float64 [0] 
var d bool       bool [false] 

짧은 변수 선언 (short variable declaration) 연산자를 사용하면 선언과 동시에 초기화를 할 수 있습니다. (예제 코드)

(이를 짧은 선언이라고 하겠습니다.)

package main

import "fmt"

func main() {
    // short variable declaration
    aa := 10
    bb := "hello"
    cc := 3.14159
    dd := true

    fmt.Printf("aa := 10 \t %T [%v] \n", aa, aa)
    fmt.Printf("bb := \"hello\" \t %T [%v] \n", bb, bb)
    fmt.Printf("cc := 3.14159 \t %T [%v] \n", cc, cc)
    fmt.Printf("dd := true \t %T [%v] \n", dd, dd)
}
[OUTPUT]
aa := 10         int [10] 
bb := "hello"    string [hello] 
cc := 3.14159    float64 [3.14159] 
dd := true       bool [true] 


주의사항

짧은 선언 연산자로 초기화한 변수는 지역 변수로만 취급됩니다. 같은 이름의 전역 변수가 존재하더라도 짧은 선언 연산자로 생성한 변수는 지역 변수로 판단하기 때문에 전역 변수가 영향을 미치지 않습니다.

단, 이 경우 해당 함수 scope 내에서는 전역 변수에 직접 접근할 수 없습니다. 컴파일 에러가 아닌 상황이므로 코드 작성 시 유의해야 합니다.

이를 Variable Shadowing이라고 합니다.(예제 코드)

package main

import "fmt"

var value int // global scope

func main() {
    // local scope
    value := "값" // 타입 추론 결과: var value string
    
    fmt.Printf("num := \"값\" \t %T [%v] \n", value, value)
}
[OUTPUT]
num := "값"      string [값] 

또한 함수 scope 내 var 키워드로 변수를 선언한 후 짧은 선언 연산자로 중복된 이름을 가지는 변수를 생성할 수 없습니다. (예제 코드)

package main

func main() {
    var value string
    value := "값" // No new variables on left sdie of :=
}

named return value를 사용할 경우에도 짧은 선언 연산자를 사용할 수 없습니다.

func getUserInfo() (name string, age int) {
    name := "Johnny" // no new variables on left side of :=
    age := 28
    
    return
}
func getUserInfo() (name string, age int) {
    name = "Johnny" // OK
    age = 28
    
    return
}

코드만 봐도 알 수 있지만 이러한 경우는 눈에 잘 띄지 않기 때문에 코드 작성 시 주의해야 합니다.

(물론 똑똑한 IDE는 다 알려줍니다 👀..)



변환과 타입 변경 (Conversion vs Casting)

Go는 Type casting을 지원하지 않고 Type conversion을 지원합니다.

Type conversion happens when we assign the value of one data type to another. Statically typed languages like
C / C++, Java, provide the support for Implicit Type Conversion but Golang is different, as it doesn’t support the Automatic Type Conversion or Implicit Type Conversion even if the data types are compatible. The reason for this is the Strong Type System of the Golang which doesn’t allow to do this. For type conversion, you must perform explicit conversion.
As per Golang Specification, there is no typecasting word or terminology in Golang. If you will try to search Type Casting in Golang Specifications or Documentation, you will find nothing like this. There is only Type Conversion. In Other programming languages, typecasting is also termed as the type conversion.


형변환(type conversion)은 값을 다른 데이터 타입으로 할당할 때 발생합니다. C / C++, Java와 같은 정적 타입 언어들은 암시적 형변환을 지원하지만 Go언어는 그렇지 않습니다. 데이터 타입이 호환되더라도 자동 형변환이나 암시적 형변환을 지원하지 않습니다. 그 이유는 Go언어의 강타입 시스템에서는 허용하지 않기 때문입니다. 형변환을 하기 위해서는 반드시 명시적 형변환으로 수행해야 합니다.
Go언어 스펙을 보면 Type casting이라는 용어가 없습니다. Type casting이란 단어를 스펙이나 문서에서 찾아 볼 수 없고 오직 Type conversion만 존재하며, 다른 프로그래밍 언어에서는 Type casting을 Type Conversion과 혼용하기도 합니다.
출처: GeeksforGeeks


타입 형변환은 다음과 같이 할 수 있습니다. (예제 코드)

package main

func main() {
    var integer int = 845

    // 명시적 Type conversion
    var fl float64 = float64(integer)
    
    var integer64 int64 = int64(integer)

    var unsigned uint = uint(integer)
}



구조체(Struct)

구조체는 기본 데이터 타입들을 그룹화하여 새롭게 정의하는 사용자 정의 데이터 타입입니다.

type example struct {
    flag      bool
    counter   int16
    pi        float32
}

example 구조체에 할당되는 메모리의 크기는 얼마일까요?

bool 타입은 1 byte, int16은 2 byte, float32는 4 byte로 총합 7 byte지만, 실제로는 8 byte를 할당합니다.

이를 이해하려면 패딩(byte padding)과 정렬 (byte alignment) 개념이 활용됩니다.

  • 바이트 패딩: 클래스나 구조체에 바이트를 추가하여 CPU 접근에 부하를 덜어주는 기법

  • 바이트 정렬: 메모리에 데이터를 저장할 때 CPU의 처리 효율을 위해 변수 크기에 맞게 정렬하여 메모리에 할당하는 방법


바이트 패딩은 bool과 int16 사이에 위치합니다.


32-bit 프로세서는 4byte씩 접근하여 연산을 합니다. 이 때 CPU가 bool, int16 데이터의 연산을 위해 4byte를 읽게 되면 그 안에 float32의 첫 byte가 포함되게 됩니다.

연산할 필요가 없는 데이터를 읽게됩니다.

위와 같이 bool 데이터를 저장할 때 1byte를 패딩하여 저장하게 된다면 CPU는 불필요하게 float32 데이터를 읽어올 필요가 없습니다.

하드웨어는 정렬 경계(byte alignment boundary)내의 메모리를 읽게 하는 것이 효율적입니다. 하드웨어가 정렬 경계에 맞춰 읽을 수 있게 소프트웨어에서 처리해주는 것이 정렬입니다.


Go언어에서는 아래의 규칙이 있습니다.

규칙 1:

특정 값의 메모리 크기에 따라 Go언어는 어떤 정렬이 필요한지 결정합니다. 모든 2 byte 크기의 값은 2 byte 경계를 가집니다.

bool 값은 1 byte이기 때문에 주소 0번지에서 시작합니다. 그러면 다음 int16 값은 2번지에서 시작해야 합니다. 건너뛰게 되는 1 byte에 패딩 1 byte가 들어갑니다. 만약 int 16이 아니라 int32라면 3 byte의 패딩이 들어갑니다.

규칙2:

가장 큰 크기의 필드가 전체 구조체의 패딩을 결정합니다. 가능한 패딩이 적을 수록 좋기 때문에 큰 필드부터 가장 작은 필드의 순서로 위치시키는 것이 좋습니다.



선언과 초기화 (Declare & Initialize)

example 구조체 타입의 변수를 선언하면 구조체 내의 필드들은 제로값으로 초기화됩니다.

var e example

fmt.Println("%+v\n", e)
[OUTPUT]
{flag:false counter:0 pi:0}

구조체 리터럴을 사용하여 내부 필드를 초기화할 수 있습니다. 이 때, 각 필드는 콤마(,)로 구분하여 초기화합니다.

e := example {
    flag:    true,
    counter: 10, 
    pi:      3.141592,
}

fmt.Println("Flag", e.flag)
fmt.Println("Counter", e.counter)
fmt.Println("Pi", e.pi)
[OUTPUT]
Counter 10
Pi 3.141592
Flag true

또한 구조체는 익명의 타입으로 선언함과 동시에 구조체 리터럴로 초기화할 수 있습니다. 단, 익명으로 선언한 구조체 타입은 재사용할 수 없습니다.

e := struct {
    flag     bool
    counter  int16
    pi       float32
}{
    flag:    true,
    counter: 10,
    pi:      3.141592,
}

fmt.Println("Flag", e.flag)
fmt.Println("Counter", e.counter)
fmt.Println("Pi", e.pi)
[OUTPUT]
Counter 10
Pi 3.141592
Flag true


이름이 있는 타입과 익명 타입 (Name type VS anonymous type)

두 구조체 타입의 필드가 완전히 같다고 하더라도, 한 타입의 구조체 변수를 다른 타입의 구조체 변수에 대입할 수는 없습니다.

type example1 struct {
    count int
}
type example2 struct {
    count int
}

var ex1 example1
var ex2 example2

ex1 = ex2 // cannot use ex2 (type example2) as type example1 in assignment

ex1 = ex2 를 올바르게 하려면 명시적인 형변환(type conversion)을 수행해야 합니다.

ex1 = example1(ex2) // OK

단, 동일한 구조의 익명 구조체 타입인 경우에는 명시적인 형변환 선언 없이도 ex1 = ex가 가능합니다.

type example1 struct {
    count int
}

var ex1 example1
var ex2 struct {
    count int
}

ex1 = ex2 // OK!

포인터

포인터란 메모리 주소를 저장하는 변수 타입입니다.

Go언어에서는 항상 값을 전달합니다.

Go언어에서 모든 데이터는 값의 전달 (Pass by value)로 작동합니다. 전달해야 할 데이터의 원본이 아닌 복사본을 만들어 전달합니다.

반면 포인터는 값을 전달하지 않고 원본 데이터의 메모리 주소 정보를 전달합니다. 그래서 전달받은 곳에서는 데이터의 메모리 주소 정보로 접근하여 데이터를 확인할 수 있습니다. (메모리 주소 또한 값으로 취급합니다.)



고루틴

메모리 관련 얘기가 나왔으니 고루틴을 잠시 언급하고 가겠습니다.

고루틴이란 2kb 크기의 스택 프레임을 가지는 경량 쓰레드입니다.

(참고: Go 언어 초창기에 고루틴에 할당되는 스택의 크기는 4kb크기였습니다. 1.2 버전에서 8kb크기로 커졌으나, 1.4 버전에서 2kb로 감소하였네요.)

Go언어에서는 main함수도 고루틴입니다. main함수를 '메인 고루틴'이라고도 칭합니다!

예제를 통해 고루틴을 어떻게 사용하는지 알아봅시다. (예제 코드)

package main

import (
    "fmt"
    "time"
)

func main() {
    go print() // 고루틴은 go 키워드를 함수 앞에 선언하여 사용
    
    time.Sleep(3 * time.Second) // 고루틴은 비동기로 수행되기 때문에 고루틴이 종료되기 전에 메인함수가 먼저 종료될 수 있다.
}

func print() {
    fmt.Println("hello, world")
}

고루틴은 비동기처럼 실행하고자 하는 함수를 호출할 때 go 키워드를 앞에 선언하여 사용할 수 있습니다.

컴파일러가 컴파일을 수행할 때 값들의 크기를 판단하여 스택에 저장합니다. 이 때 스택 프레임의 크기가 결정됩니다. 만약 컴파일러가 크기를 알 수 없는 값들이 있다면 이 값들은 힙에 저장합니다.



값의 전달 (Pass by value)

int 타입의 변수의 값을 10으로 선언하면 스택에 저장됩니다.

스택에 저장된 변수 메모리 주소에 접근하려면 다음과 같이 할 수 있습니다. (예제 코드)

package main

import "fmt"

func main() {
    count := 10
    
    fmt.Println("value: ", count) // count의 값 전달
    fmt.Println("addr: ", &count) // count의 메모리 주소를 값으로 전달
}
[OUTPUT]
value: 10
addr: 0xc00050738

& 문자를 변수 앞에 선언하여 메모리 주소를 값으로 함수에 전달할 수 있습니다.

좀 더 자세히 알아볼까요? (예제 코드)

package main

import "fmt"

func main() {
    count := 10
    
    // count 값을 전달
    increase(count)
    
    fmt.Println("count: ", count)
    fmt.Println("count.addr: ", &count)
}

func increase(v int) {
    v++
    
    fmt.Println("v: ", v)
    fmt.Println("v.addr: ", &v)
}
[OUTPUT]
v: 11
v.addr: 0xc0000b8018

count: 10
count.addr: 0xc0000b6020

increase 함수에 'count의 값'을 전달했습니다. increase 내부에서는 전달 받은 count의 값에 +1 연산을 수행하는 코드입니다.

그러나 출력해보면 서로 값이 다른데요. 이는 increase 함수로 값을 전달할 때 값이 복사되어 전달되었기 때문입니다.

v 메모리 영역에는 10이라는 값이 복사되어 할당되었습니다. 그렇기 때문에 산술 연산을 시도하면 v 메모리 영역에 할당된 값에 연산 작업이 이루어지게 됩니다.




반면 포인터를 사용하게 되면 이야기가 다릅니다. (예제 코드)

package main

import "fmt"

func main() {
    count := 10
    
    // count 값의 메모리 주소를 전달 (이 또한 pass by value로 작동한다.)
    increase(&count)
    
    fmt.Println("count: ", count)
    fmt.Println("addr: ", &count)
}

func increase(v *int) { // 타입 앞에 * 문자를 명시하여 int의 포인터 타입으로 선언
    *v++ // 포인터 변수를 연산하려면 해당 포인터를 역참조하여 값에 접근해야 한다.
    
    fmt.Println("v: ", v)
    fmt.Println("v.addr: ", &v)
}
[OUTPUT]
v:  0xc0000b6020 // count.addr의 정보를 값으로 가지고 있는다.
v.addr:  0xc0000b8018 // count.addr의 정보값을 가지는 v 변수의 메모리 주소

count:  11 // 원본에 연산처리가 반영됨
count.addr:  0xc0000b6020

increase 함수에 count 값의 메모리 주소를 전달하였습니다. 이를 참조(reference)라고 합니다.


increase 함수의 v 변수는 포인터로, count 값의 메모리 주소를 "값으로" 할당받게 됩니다.

이후 산술 연산을 위해 증가연산자를 사용했는데요. 이 때 포인터 값을 연산처리 하려면 해당 포인터를 역참조(dereference) 해야합니다.


역참조를 하게 되면 count의 메모리 주소값이 아닌 값을 바라보게 되기 때문에 산술 연산이 가능해집니다.


포인터 크기

포인터 변수의 크기는 "빌트인 타입" 챕터에서도 설명했지만 uintptr 이라는 데이터 타입으로, uint 이기 때문에 CPU 아키텍쳐에 따라 4byte 혹은 8byte입니다. (예제 코드)

package main

import (
    "fmt"
    "runtime"
    "unsafe"
)

type human struct {
    name string
    age int32
}

func main() {
    johnny := human{
        name: "Johnny", 
        age: 28, 
    }

    var johnnyPtr *human
    johnnyPtr = &johnny

    fmt.Println("Architecture: ", runtime.GOARCH)
    fmt.Println("johnny: ", unsafe.Sizeof(johnny), "byte")
    fmt.Println("johnnyPtr: ", unsafe.Sizeof(johnnyPtr), "byte")
}
[OUTPUT]
Architecture:  amd64
johnny:        24 byte
johnnyPtr:     8 byte

지금까지의 스터디한 내용이 이해가 되었다면 위의 OUTPUT에서 "johnny" 구조체의 크기가 24byte인 것을 설명할 수 있어야 합니다!

profile
배우면 까먹는 개발자 😵‍💫

0개의 댓글