변수·상수·기본 타입 - 제로값이 있는 언어
이 장에서 배우는 것
프로그램은 값을 담아 두고 계산한 뒤 결과를 보여 주는 일을 반복한다. 값을 담는 그릇이 변수이고, 그릇에 담을 수 있는 값의 종류가 타입(type)이다. Go는 변수를 선언하기만 해도 그 안에 항상 정해진 값이 들어 있게 만든 언어다. 이 장에서는 동네 자전거 대여소의 자전거 수, 점유율, 요금, 옵션을 다루면서 변수와 기본 타입, 상수를 익힌다.
var와:=로 변수를 선언하고, 언제 무엇을 쓰는지 구분한다.- 선언만 한 변수가 갖는 제로값(zero value)을 타입별로 말할 수 있다.
int,float64,string,bool,rune의 쓰임을 알고, 서로 다른 타입 사이에서는 명시적으로 형 변환(type conversion)을 한다.const와iota로 이름 붙은 상수와 순번, 비트 플래그를 만든다.
문제 상황
대여소 화면에 다음 정보를 띄우려고 한다. 거치대 20개 중 자전거가 14대 있으니 점유율은 몇 퍼센트인지, 47분을 빌린 사람의 요금은 얼마인지, 자전거에 헬멧과 바구니가 달려 있는지를 보여 주어야 한다. 대여소 이름은 한글이다.
처음 짠 코드는 흔히 다음 네 곳에서 막힌다.
- 14대를 20칸으로 나누었는데 점유율이 0으로 나온다.
- 정수 대수와 소수 요금을 곱하려 했더니 컴파일이 되지 않는다.
- 대여소 이름 길이를
len으로 재었더니 8이 아니라 22가 나온다. - 옵션이 늘어날 때마다
true/false변수가 하나씩 늘어난다.
모두 변수와 타입의 규칙을 정확히 알면 사라지는 문제다. 이 장의 규칙을 하나씩 확인하고, 마지막에 이 문제들을 한 파일에서 모두 풀어 본다.
변수 선언과 제로값
var 와 :=
Go에서 변수를 만드는 방법은 두 가지다. 하나는 var 로 타입을 밝히는 선언이고, 다른 하나는 함수 안에서만 쓸 수 있는 짧은 선언 := 이다.
var docks int = 20 // 타입과 값을 모두 적는다
var spare = 3 // 값에서 타입을 알아낸다(int)
bikes := 14 // var 와 타입 표기를 생략한 짧은 형태
bikes = 15 // 이미 있는 변수에 새 값을 넣을 때는 = 만 쓴다
:= 는 "새 변수를 만들고 값을 넣는다"는 뜻이고, = 는 "이미 있는 변수에 값을 넣는다"는 뜻이다. 오른쪽 값의 타입이 그대로 변수의 타입이 된다. 정수 리터럴(코드에 직접 적은 값) 14 는 int, 3.5 는 float64, "B-017" 은 string, 'B' 는 rune 이 된다.
Go 컴파일러는 쓰지 않는 지역 변수를 오류로 처리한다(declared and not used). 다른 언어에서 경고로 넘어갈 일을 Go는 컴파일 단계에서 막는다. 함수 밖, 즉 패키지 수준에서는 := 를 쓸 수 없고 var 만 쓴다.
제로값
C 계열 언어에서는 초기화하지 않은 변수에 이전에 메모리에 있던 값이 남아 있을 수 있다. Go는 그런 일이 없다. 값을 주지 않고 선언한 변수는 타입마다 정해진 제로값으로 시작한다.
| 타입 | 제로값 | 출력 서식 | 의미 |
|---|---|---|---|
int | 0 | %d | 숫자 0 |
float64 | 0 | %.1f | 소수 0.0 |
string | "" | %q | 글자가 없는 문자열 |
bool | false | %t | 거짓 |
포인터·슬라이스·맵 같은 타입의 제로값은 nil 이라는 "가리키는 것이 없음" 값이다. 이 타입들은 각각을 설명할 때 다룬다.
제로값이 유용한 이유는 "선언만 해도 쓸 수 있는 상태"가 되기 때문이다. 예를 들어 오늘 반납 건수를 세는 변수는 var returned int 한 줄이면 0에서 시작하는 카운터가 된다. 값을 넣는 줄을 따로 쓰지 않아도 되고, 잊어서 생기는 오류도 없다.
기본 타입과 명시적 형 변환
기본 타입
| 타입 | 크기 | 담는 값 | 리터럴 예 |
|---|---|---|---|
int | 32 또는 64비트 | 부호 있는 정수 | 14 |
float64 | 64비트 | 소수(부동소수점) | 3.99 |
string | 가변 | 바이트 열, 수정할 수 없음 | "B-017" |
bool | 1바이트 | 참 또는 거짓 | true |
rune | 32비트 | 글자 하나의 번호 | 'B' |
int 의 크기는 실행하는 컴퓨터에 따라 다르다. 요즘 macOS와 Linux 컴퓨터는 대부분 64비트다. 크기를 정확히 고정하고 싶으면 int8, int32, int64, uint8 처럼 이름에 크기가 붙은 타입을 쓴다. 특별한 이유가 없으면 정수는 int, 소수는 float64 를 쓰면 된다. byte 는 uint8 의 다른 이름이고 rune 은 int32 의 다른 이름이다.
명시적 형 변환
Go는 서로 다른 타입의 값을 자동으로 바꾸지 않는다. int 와 float64 를 곱하려면 개발자가 어느 쪽으로 맞출지 직접 적어야 한다. 문법은 타입(값) 이다.
hours := float64(rentalMinutes) / 60 // int 를 float64 로
whole := int(avgKm) // float64 를 int 로: 소수 부분은 버려진다
소수를 정수로 바꾸면 반올림이 아니라 소수점 아래를 잘라 낸다. 3.99는 3이 된다. 번거로워 보이지만 어디서 정밀도를 잃는지 코드에 그대로 드러나는 장점이 있다.
정수끼리 나누면 결과도 정수다. 14 / 20 은 0이고 47 / 10 은 4이며, 나머지는 % 로 구한다(47 % 10 은 7). 요금을 "10분 단위로 올림"하려면 이 성질을 이용해 (분 + 9) / 10 으로 계산한다.
문자열과 rune
문자열은 바이트(byte, 8비트 수) 열이다. Go 소스와 문자열은 UTF-8 이라는 방식으로 글자를 바이트로 바꾸어 저장하는데, 영문 한 글자는 1바이트이지만 한글 한 글자는 3바이트를 쓴다. 그래서 len("한강") 은 2가 아니라 6이다.
글자 수가 필요하면 문자열을 []rune(s) 로 바꾸어 그 길이를 재거나 utf8.RuneCountInString(s) 를 쓴다. rune(룬)은 유니코드에서 글자 하나에 붙은 번호를 담는 타입이다. 'B' 처럼 작은따옴표로 적으면 그 글자의 번호(66)를 가진 값이 된다. 큰따옴표 "B" 는 문자열이므로 둘은 다른 타입이다. 문자열의 s[0] 은 글자가 아니라 첫 바이트를 돌려준다. 문자열의 내용은 바꿀 수 없어서 s[0] = 'b' 는 컴파일 오류다.
상수와 iota
바뀌지 않는 값은 const 로 선언한다. 기본 요금이나 단위 시간이 그런 예다. 상수는 컴파일할 때 값이 정해지고, 나중에 대입하려 하면 컴파일 오류가 난다.
타입을 적지 않은 상수는 "타입 없는 상수"이고, 쓰이는 자리에 맞춰 타입이 정해진다. unitFee 가 200 인 상수라면 int 변수와도, float64 변수와도 섞어 쓸 수 있다. 변수는 이렇게 되지 않는다는 점이 상수의 편한 부분이다.
iota
iota 는 const 묶음 안에서 0부터 1씩 커지는 값을 만들어 준다. 자전거 종류처럼 번호를 붙이는 데 쓴다.
const (
Standard = iota // 0
Electric // 1: 식을 생략하면 앞 줄의 식이 반복된다
Cargo // 2
)
식에 iota 를 넣으면 규칙에 따른 값이 나온다. 1 << iota 는 1을 왼쪽으로 iota 칸 미는 계산이라 1, 2, 4, 8로 늘어난다. 이진수로 쓰면 001, 010, 100 이어서 자리마다 켜고 끌 수 있는 스위치가 된다. 이것을 비트 플래그라고 한다. 옵션 여러 개를 bool 변수 여러 개 대신 정수 하나에 담을 수 있다. 합치는 것은 |, 확인하는 것은 & 로 한다.
완성 코드
앞 장에서 만든 모듈 폴더에 main.go 로 저장한다.
package main
import (
"fmt"
"unicode/utf8"
)
const stationName = "한강공원 대여소"
const (
Standard = iota
Electric
Cargo
)
const (
FlagHelmet = 1 << iota
FlagLock
FlagBasket
)
const (
baseFee = 1000
unitFee = 200
unitMinutes = 10
discountPercent = 15
)
func main() {
// 1. 제로값
var stationID int
var lateRate float64
var note string
var closed bool
fmt.Printf("제로값: %d %.1f %q %t\n", stationID, lateRate, note, closed)
// 2. var 와 :=
var docks int = 20
bikes := 14
occupancy := float64(bikes) / float64(docks) * 100
fmt.Printf("%s: 거치대 %d개 중 %d대 (%.1f%%)\n", stationName, docks, bikes, occupancy)
// 3. 요금 계산
rentalMinutes := 47
units := (rentalMinutes + unitMinutes - 1) / unitMinutes
fee := baseFee + units*unitFee
discounted := fee * (100 - discountPercent) / 100
fmt.Printf("대여 %d분: %d단위, 요금 %d원, 할인 후 %d원\n", rentalMinutes, units, fee, discounted)
fmt.Println("몫과 나머지:", rentalMinutes/unitMinutes, rentalMinutes%unitMinutes)
// 4. 형 변환
hours := float64(rentalMinutes) / 60
avgKm := 3.99
fmt.Printf("%.2f시간, 평균 %.2fkm는 정수로 %d\n", hours, avgKm, int(avgKm))
var half float64 = unitFee / 2
fmt.Printf("단위 요금의 절반: %.1f\n", half)
// 5. 문자열과 rune
runes := []rune(stationName)
fmt.Printf("바이트 %d, 글자 %d, 앞 두 글자 %s\n", len(stationName), utf8.RuneCountInString(stationName), string(runes[:2]))
bikeID := "B-017"
fmt.Println(stationName + " / " + bikeID)
prefix := rune(bikeID[0])
fmt.Printf("%c %d %t\n", prefix, prefix, prefix == 'B')
// 6. iota
fmt.Println("종류:", Standard, Electric, Cargo)
options := FlagHelmet | FlagBasket
fmt.Printf("옵션 %03b, 잠금장치 포함 %t, 바구니 포함 %t\n", options, options&FlagLock == FlagLock, options&FlagBasket == FlagBasket)
}
줄별 해설
선언부. stationName 은 문자열 상수다. 첫 번째 const 묶음은 자전거 종류 번호를 0, 1, 2로 만든다. 두 번째 묶음은 1 << iota 로 1, 2, 4를 만들어 헬멧, 잠금장치, 바구니 플래그로 쓴다. 세 번째 묶음은 요금 규칙이다. 이 값들은 코드 곳곳에 숫자로 흩어 놓지 않고 이름을 붙여 한곳에 모았다.
1번 블록. 값을 주지 않은 int, float64, string, bool 변수 네 개를 출력한다. %d 는 정수, %.1f 는 소수점 아래 한 자리, %q 는 따옴표를 붙인 문자열, %t 는 참/거짓이다. 빈 문자열이 "" 로 보이는 것은 %q 덕분이다.
2번 블록. var docks int = 20 은 타입을 모두 적은 선언이고 bikes := 14 는 짧은 선언이다. 점유율은 두 값을 float64 로 바꾼 뒤 나누어 0.7을 얻고 100을 곱한다. %% 는 서식 문자열에서 퍼센트 기호 하나를 뜻한다.
3번 블록. (47 + 10 - 1) / 10 은 정수 나눗셈이라 5가 되고, 이것이 10분 단위 올림 결과다. 요금은 1000 + 5 * 200 = 2000 이다. 할인은 소수를 쓰지 않고 2000 * 85 / 100 으로 계산해 1700원이다. 돈을 정수(원 단위)로 다루면 소수 오차를 피할 수 있다. 다음 줄은 같은 47과 10에서 몫 4와 나머지 7을 보여 준다.
4번 블록. float64(rentalMinutes) / 60 은 소수 나눗셈이라 약 0.783이 되고 %.2f 가 0.78로 표시한다. int(avgKm) 은 3.99의 소수 부분을 잘라 3을 만든다. var half float64 = unitFee / 2 에서는 타입 없는 상수 200 / 2 가 100이 되어 float64 변수에 그대로 들어간다. 상수가 아니라 int 변수였다면 이 대입은 컴파일되지 않는다.
5번 블록. len(stationName) 은 바이트 수 22를, utf8.RuneCountInString 은 글자 수 8을 돌려준다. runes[:2] 는 앞 두 글자만 잘라 낸 것이고 string(...) 으로 다시 문자열로 바꾼다. 문자열은 + 로 이을 수 있다. bikeID[0] 은 첫 바이트이고 rune(...) 으로 바꾼 뒤 %c(글자), %d(번호)로 출력한다. 'B' 와 비교한 결과는 true 다.
6번 블록. 세 종류 상수를 출력하면 0, 1, 2다. FlagHelmet | FlagBasket 은 001 과 100 을 합쳐 101 이다. %03b 는 이진수를 세 자리로 채워 보여 준다. options & FlagLock 은 잠금장치 자리만 남기는데 그 자리가 꺼져 있으니 0이고, FlagLock(2)과 같지 않으므로 false 다. 바구니 자리는 켜져 있어서 true 다. 이 프로그램은 쓰지 않는 변수나 가져오고 쓰지 않는 패키지가 없어 경고 없이 컴파일된다.
실행 결과
$ go run .
제로값: 0 0.0 "" false
한강공원 대여소: 거치대 20개 중 14대 (70.0%)
대여 47분: 5단위, 요금 2000원, 할인 후 1700원
몫과 나머지: 4 7
0.78시간, 평균 3.99km는 정수로 3
단위 요금의 절반: 100.0
바이트 22, 글자 8, 앞 두 글자 한강
한강공원 대여소 / B-017
B 66 true
종류: 0 1 2
옵션 101, 잠금장치 포함 false, 바구니 포함 true
실무에서 자주 틀리는 것
1. 함수 밖에서 := 를 쓴다
package main
bikes := 14 // 컴파일 오류: non-declaration statement outside function body
func main() {}
패키지 수준에서는 선언이 var, const, func, type 으로 시작해야 한다. 고친 코드는 다음과 같다.
package main
var bikes = 14
func main() {}
2. 정수와 실수를 그대로 섞는다
bikes := 14
price := 1500.5
total := bikes * price // 컴파일 오류: mismatched types int and float64
어느 쪽을 바꿀지 코드에 적어야 한다. 요금 계산이라면 소수로 맞춘다.
total := float64(bikes) * price
3. 정수 나눗셈으로 비율을 구한다
ratio := bikes / docks // 14 / 20 은 0
percent := float64(bikes/docks) * 100 // 나눈 뒤에 바꾸므로 여전히 0
형 변환은 나눗셈 전에 해야 한다.
percent := float64(bikes) / float64(docks) * 100 // 70
4. 한글 문자열의 길이를 len 으로 잰다
name := "한강공원 대여소"
if len(name) > 10 { // 바이트 22 로 판단한다
// ...
}
화면에 보이는 글자 수가 기준이라면 룬을 센다. (조건문 if 는 다음 장에서 자세히 다룬다.)
n := utf8.RuneCountInString(name) // 8
한눈에 보기
| 주제 | 문법 | 핵심 | 주의 |
|---|---|---|---|
| 변수 선언 | var x int, x := 1 | := 는 함수 안에서 새 변수를 만든다 | 패키지 수준에서는 var 만 가능하고, 쓰지 않는 지역 변수는 오류다 |
| 제로값 | 0, 0.0, "", false | 선언만 해도 값이 정해져 있다 | 포인터·슬라이스·맵은 nil |
| 형 변환 | float64(n), int(f) | 타입이 다르면 직접 바꾼다 | 소수를 정수로 바꾸면 잘린다 |
| 문자열과 rune | len(s), []rune(s) | len 은 바이트 수 | 한글은 글자당 3바이트 |
| 상수 | const, iota | 번호와 비트 플래그를 이름으로 만든다 | 상수에는 다시 대입할 수 없다 |
| 서식 | 대상 | 예 | 출력 |
|---|---|---|---|
%d | 정수 | 14 | 14 |
%.2f | 소수 | 0.7833 | 0.78 |
%q | 문자열 | "" | "" |
%c | rune | 66 | B |
연습 문제
- 다음 코드의 출력을 예상하라.
var a int var b string c := 7 / 2 d := 7 / 2.0 fmt.Println(a, b == "", c, d) - 다음 코드는 컴파일되지 않는다. 이유를 말하고 고쳐라.
bikes := 14 price := 1500.5 total := bikes * price - 자전거 상태
Available,Rented,Broken을iota로 선언하되 값이 0이 아닌 1부터 시작하게 하라. 이때Rented의 값은 얼마인가? s := "자전거 A"의len(s)와 글자 수를 각각 구하라.
정답과 해설
- 출력은
0 true 3 3.5다.a는 제로값 0이고b는 빈 문자열이라b == ""가 참이다.7 / 2는 정수 나눗셈이라 3이다.2.0은 소수 리터럴이므로7 / 2.0은 소수 계산이 되어d는float643.5다. bikes는int,price는float64라서 곱할 수 없다(mismatched types).total := float64(bikes) * price로 고친다. 반대로int(price)로 바꾸면 소수 부분이 잘리므로 요금 계산에는 맞지 않다.-
const ( Available = iota + 1 Rented Broken )Rented는 2다. 첫 줄의 식iota + 1이 뒤 줄에도 반복되어 1, 2, 3이 된다. 제로값 0을 "정해지지 않은 상태"로 남겨 두고 싶을 때 이렇게 시작 값을 옮긴다. len(s)는 11이다. "자전거"는 한글 3글자라 9바이트, 공백 1바이트,A1바이트를 더한다. 글자 수는utf8.RuneCountInString(s)로 구하며 5다.