상수와 변수 - let 을 기본으로
이 장에서 배우는 것
앞 장에서는 화면 없이 Swift 파일 하나를 컴파일하고 실행하는 흐름을 익혔다. 이 장에서는 프로그램이 값을 어떻게 이름 붙여 보관하는지 다룬다. 습관 기록 앱에서 하루에 마신 물의 양, 잔 수, 목표 달성 여부는 모두 값이고, 값에는 이름과 종류가 있다. 이 이름과 종류를 정하는 규칙이 이 장의 주제다.
let과var의 차이를 설명하고, 기본으로let을 고르는 이유를 말할 수 있다.- 타입 추론과 타입 명시를 구분해 쓰고,
Int·Double·String·Bool의 용도를 안다. - 서로 다른 숫자 타입을 명시적으로 변환해 달성률을 계산할 수 있다.
- 정수 오버플로(overflow, 정수가 담을 수 있는 범위를 넘는 일)를 검사하는 방법을 안다.
- 튜플(tuple)로 값 몇 개를 묶고 꺼내 쓸 수 있다.
문제 상황
물 마시기 기록 기능을 만든다고 하자. 하루 목표는 2000ml이고, 사용자가 컵을 비울 때마다 마신 양이 늘어난다. 여기서 값은 성격이 다른 두 종류로 나뉜다. 목표량은 하루 동안 바뀌지 않지만, 마신 양은 기록할 때마다 바뀐다.
이 둘을 구분하지 않고 모두 바꿀 수 있는 값으로 두면 문제가 생긴다. 코드 어딘가에서 실수로 목표량을 덮어써도 프로그램은 아무 말 없이 돌아가고, 달성률만 이상하게 나온다. 며칠 뒤에야 "목표가 왜 500이 되어 있지"라는 질문이 나오고, 어디서 바뀌었는지 찾으려면 코드 전체를 읽어야 한다.
숫자 계산에도 함정이 있다. 마신 양 1250을 목표 2000으로 나누면 0.625가 나와야 하는데, 정수끼리 나누면 0이 나온다. 또 연속 달성일을 작은 정수 타입에 담아 두었다가 한계를 넘기면 값이 엉뚱하게 돌아가거나 프로그램이 멈춘다. Swift는 이런 실수를 코드를 쓰는 단계나 실행 초기에 드러내도록 설계되어 있다. 그 규칙을 하나씩 살펴본다.
let과 var, 그리고 타입
let은 고정, var는 변경 가능
Swift에서 값에 이름을 붙이는 방법은 두 가지다. let으로 만든 것은 상수(constant)로, 한 번 값을 정하면 바꿀 수 없다. var로 만든 것은 변수(variable)로, 나중에 다른 값을 대입할 수 있다.
let dailyGoalMl = 2000
var drankMl = 0
drankMl = 250 // 가능
dailyGoalMl = 2500 // 컴파일 오류: let 으로 만든 값은 바꿀 수 없다
이 책은 let을 기본으로 쓰고, 값이 실제로 바뀌어야 할 때만 var로 바꾼다. 이유는 읽는 사람에게 정보를 주기 때문이다. 코드에서 let을 보면 "이 이름은 끝까지 같은 값을 가리킨다"고 믿고 읽어도 된다. var를 보면 "이 값이 어디선가 바뀐다"는 신호이므로 바뀌는 지점을 따라가며 읽는다. 바뀌지 않는 값까지 var로 만들면 이 신호가 흐려진다. 또 var로 선언하고 한 번도 바꾸지 않으면 컴파일러가 경고를 낸다. 이 책의 완성 코드는 경고 없이 컴파일돼야 하므로, 처음에는 let으로 쓰고 컴파일러가 바꾸라고 할 때 var로 고치는 습관이 편하다.
타입 추론과 타입 명시
Swift의 모든 값에는 타입(type, 값의 종류)이 있다. 타입은 그 값에 어떤 연산을 할 수 있는지를 정한다. 다행히 매번 적을 필요는 없다. 오른쪽 값을 보고 컴파일러가 타입을 정해 주는데, 이를 타입 추론(type inference)이라 한다.
let cupSizeMl = 250 // Int 로 추론
let ratio = 0.5 // Double 로 추론
let dailyGoalMl: Int = 2000 // 타입을 직접 적음(명시)
let weightKg: Double = 70 // 정수 리터럴이지만 Double 로 만든다
이름 뒤에 : 타입을 붙이면 타입을 직접 지정한다. 추론 결과가 이미 원하는 타입이면 생략하는 것이 보통이다. 명시가 필요한 때는 두 가지다. 위의 weightKg처럼 정수처럼 보이는 숫자를 Double로 쓰고 싶을 때, 그리고 나중에 값을 넣기 위해 타입만 먼저 정해 둘 때다. 추론된 타입을 확인하고 싶으면 type(of:)로 출력해 볼 수 있다.
네 가지 기본 타입
| 타입 | 담는 값 | 예 | 습관 앱에서의 용도 |
|---|---|---|---|
Int | 정수 | 250 | 마신 양(ml), 잔 수, 연속 달성일 |
Double | 소수가 있는 수 | 0.625 | 달성률, 평균값 |
String | 글자 | "물 마시기" | 습관 이름, 출력 문구 |
Bool | 참 또는 거짓 | true | 오늘 목표 달성 여부 |
문자열 안에 값을 끼워 넣을 때는 \( )를 쓴다. 이를 문자열 보간(string interpolation)이라 하며, "잔 수: \(cupCount)"처럼 쓰면 값이 글자로 바뀌어 들어간다. Bool은 비교 연산의 결과로 자주 나온다. drankMl >= dailyGoalMl은 마신 양이 목표 이상이면 true, 아니면 false다.
형 변환과 오버플로
명시적 형 변환
Swift는 서로 다른 숫자 타입을 자동으로 섞지 않는다. Int와 Double을 한 식에 넣으면 컴파일 오류가 난다. 다른 언어에서는 조용히 변환되어 의도하지 않은 결과가 나오기도 하는데, Swift는 변환을 코드에 드러내게 한다. 타입 이름을 함수처럼 써서 바꾼다.
let drankMl = 1250
let dailyGoalMl = 2000
let rate = Double(drankMl) / Double(dailyGoalMl) // 0.625
let percent = Int(rate * 100) // 62
두 방향의 성격이 다르다. Int를 Double로 바꾸면 값이 그대로 유지된다. 반대로 Double을 Int로 바꾸면 소수점 아래를 반올림하지 않고 버린다. 62.5는 62가 된다. 반올림이 필요하면 rate * 100에 .rounded()를 적용한 뒤 변환해야 하는데, 이 장에서는 버림만 쓴다.
정수끼리 나누면 결과도 정수다. 1250 / 2000은 0.625가 아니라 0이다. 달성률을 구하려면 나누기 전에 한쪽 이상을 Double로 바꿔야 한다.
오버플로 검사
정수 타입은 담을 수 있는 범위가 정해져 있다. 흔히 쓰는 Int는 64비트 환경에서 약 ±922경까지 담지만, 크기가 작은 Int8은 -128부터 127까지다. 범위를 넘는 계산을 오버플로라고 한다.
일반 연산자 +는 오버플로가 일어나면 프로그램을 멈춘다. 잘못된 값으로 계속 진행하는 것보다 멈추는 쪽이 안전하다는 판단이다. 그런데 값이 큰지 작은지 모르는 상황에서 멈추지 않고 확인만 하고 싶을 때가 있다. 이때 addingReportingOverflow(_:)를 쓴다. 이 메서드는 계산 결과와 넘침 여부를 함께 돌려준다. 결과는 partialValue와 overflow라는 이름이 붙은 묶음이다. 묶음의 정체는 다음 절의 튜플이다.
또 하나, &+ 같은 앞에 &가 붙은 연산자는 범위를 넘으면 반대쪽 끝으로 돌아가서(wrap around) 계산한다. 128이 되어야 할 자리에 -128이 나오는 식이다. 오류를 알려 주지 않으므로, 일부러 이런 동작이 필요할 때만 쓴다.
| 방법 | 넘쳤을 때 동작 | 알아채는 방법 | 쓰는 때 |
|---|---|---|---|
+ | 프로그램이 멈춘다 | 실행 중 오류 | 넘치지 않는다고 확신할 때 |
addingReportingOverflow | 돌아간 값과 true를 돌려준다 | overflow 값 확인 | 넘침을 직접 처리해야 할 때 |
&+ | 반대쪽 끝으로 돌아간다 | 알 수 없다 | 돌아가는 동작이 의도일 때 |
튜플로 값 묶기
튜플은 값 몇 개를 괄호로 묶은 것이다. 묶인 값의 타입은 서로 달라도 된다. 요소에 이름을 붙이면 .으로 꺼낼 수 있다.
let summary = (name: "물 마시기", drank: 1250, goal: 2000)
print(summary.name) // 물 마시기
print(summary.drank) // 1250
튜플을 변수 여러 개로 한 번에 풀어 쓰는 것을 분해(decomposition)라 한다. 필요 없는 자리는 _로 비운다.
let (_, drankCopy, goalCopy) = summary
튜플은 관련된 값 두세 개를 임시로 묶을 때 알맞다. 앞 절의 addingReportingOverflow가 결과와 넘침 여부를 함께 돌려주는 것이 그 예다. 묶음이 커지거나 여러 곳에서 반복해 쓰게 되면 이름을 가진 새 타입으로 만드는 편이 낫다. 그 방법은 구조체를 다루는 장에서 배운다.
완성 코드
아래 프로그램은 하루치 물 마시기 기록을 계산한다. 파일 이름은 main.swift다.
// 습관 기록 앱: 하루 물 마시기 기록과 달성률 계산
let habitName = "물 마시기"
let dailyGoalMl: Int = 2000
let cupSizeMl = 250
var drankMl = 0
var cupCount = 0
// 컵을 네 번 비운다: 250, 250, 500, 250
drankMl += cupSizeMl
cupCount += 1
drankMl += cupSizeMl
cupCount += 1
drankMl += 500
cupCount += 1
drankMl += cupSizeMl
cupCount += 1
print("습관: \(habitName)")
print("목표: \(dailyGoalMl)ml, 마신 양: \(drankMl)ml, 잔 수: \(cupCount)")
// 형 변환과 달성률
let rate = Double(drankMl) / Double(dailyGoalMl)
print("rate 타입: \(type(of: rate))")
let percent = Int(rate * 100)
let goalReached = drankMl >= dailyGoalMl
let remainingMl = dailyGoalMl - drankMl
print("달성률: \(percent)%")
print("달성 여부: \(goalReached)")
print("부족한 양: \(remainingMl)ml")
// 정수끼리 나누면 소수점이 사라진다
let wrongRate = drankMl / dailyGoalMl
print("정수 나눗셈 결과: \(wrongRate)")
let averagePerCup = Double(drankMl) / Double(cupCount)
print("한 잔 평균: \(averagePerCup)ml")
// 튜플로 묶기와 분해
let summary = (name: habitName, drank: drankMl, goal: dailyGoalMl)
print("요약: \(summary.name) \(summary.drank)/\(summary.goal)")
let (_, drankCopy, goalCopy) = summary
print("분해: \(drankCopy) / \(goalCopy)")
// 오버플로 검사
let streak: Int8 = 120
let checked = streak.addingReportingOverflow(10)
print("검사 결과: 넘침 \(checked.overflow), 값 \(checked.partialValue)")
let wrapped = streak &+ 10
print("&+ 결과: \(wrapped)")
print("Int8 범위: \(Int8.min) ~ \(Int8.max)")
print("Int 최댓값: \(Int.max)")
줄별 해설
let habitName,let dailyGoalMl: Int,let cupSizeMl: 하루 동안 바뀌지 않는 값이라let이다. 목표량에는 타입을 명시했고, 나머지는 추론에 맡겼다. 둘 다 결과는Int다.var drankMl = 0,var cupCount = 0: 기록할 때마다 늘어나므로var다. 초깃값 0에서Int로 추론된다.drankMl += cupSizeMl:drankMl = drankMl + cupSizeMl을 줄여 쓴 것이다. 세 번째 기록은 리터럴500을 직접 더했다. 반복되는 이 코드는 컬렉션과 제어 흐름을 배우면 줄어든다.Double(drankMl) / Double(dailyGoalMl): 두 정수를Double로 바꾼 뒤 나눠 0.625를 얻는다.type(of: rate): 추론된 타입을 출력해Double임을 확인한다.Int(rate * 100): 62.5를Int로 바꾸며 소수 부분을 버려 62가 된다.drankMl >= dailyGoalMl: 1250이 2000 이상이 아니므로false다.goalReached는Bool로 추론된다.drankMl / dailyGoalMl: 정수끼리 나눗셈이라 몫만 남아 0이다. 실수가 어떤 모습인지 보이려고 일부러 넣었다.Double(drankMl) / Double(cupCount): 1250을 4로 나눈 312.5다. 평균은 정수로 딱 떨어지지 않으므로Double이 알맞다.let summary = (name: ..., drank: ..., goal: ...): 이름 붙은 튜플이다.summary.name처럼 꺼낸다.let (_, drankCopy, goalCopy) = summary: 첫 요소는 쓰지 않으므로_로 버리고, 나머지 둘을 상수로 푼다.let streak: Int8 = 120: 일부러 작은 정수 타입을 골랐다. 120에 10을 더하면 130으로Int8범위를 넘는다.streak.addingReportingOverflow(10): 넘침 여부true와 돌아간 값 -126을 튜플로 돌려준다. 130에서 256을 뺀 값이다.streak &+ 10: 같은 계산을 돌아가는 방식으로 하며, 오류 없이 -126이 나온다.Int8.min,Int8.max,Int.max: 타입이 담을 수 있는 한계를 타입 자체에서 읽는다.
실행 결과
$ swiftc main.swift -o app
$ ./app
습관: 물 마시기
목표: 2000ml, 마신 양: 1250ml, 잔 수: 4
rate 타입: Double
달성률: 62%
달성 여부: false
부족한 양: 750ml
정수 나눗셈 결과: 0
한 잔 평균: 312.5ml
요약: 물 마시기 1250/2000
분해: 1250 / 2000
검사 결과: 넘침 true, 값 -126
&+ 결과: -126
Int8 범위: -128 ~ 127
Int 최댓값: 9223372036854775807
마지막 줄은 64비트 환경에서의 값이다. 이 책이 대상으로 하는 macOS와 Linux의 일반적인 환경은 모두 64비트다. 정확한 규칙은 The Swift Programming Language의 기본 연산자 항목에서도 확인할 수 있다.
실무에서 자주 틀리는 것
바뀌지 않는 값을 var로 선언한다
틀린 코드:
var dailyGoalMl = 2000
print(dailyGoalMl)
값을 바꾸는 곳이 없으므로 컴파일러가 "var로 선언했지만 한 번도 바뀌지 않았다"는 경고를 낸다. 경고를 무시하면 읽는 사람이 어딘가에서 바뀐다고 오해한다.
고친 코드:
let dailyGoalMl = 2000
print(dailyGoalMl)
Int와 Double을 그대로 섞는다
틀린 코드:
let drankMl = 1250
let rate = drankMl / 2000.0 // 컴파일 오류: Int 와 Double 을 섞을 수 없다
고친 코드:
let drankMl = 1250
let rate = Double(drankMl) / 2000.0
리터럴 2000.0은 이미 Double이므로 변수 쪽만 바꾸면 된다.
정수 나눗셈으로 비율을 구한다
틀린 코드:
let rate = Double(drankMl / dailyGoalMl) // 나눗셈이 먼저 정수로 계산되어 0.0
괄호 안에서 정수 나눗셈이 끝난 뒤에야 Double로 바뀌므로 이미 0이다. 컴파일 오류도 경고도 없어 찾기 어렵다.
고친 코드:
let rate = Double(drankMl) / Double(dailyGoalMl) // 먼저 바꾸고 나눈다
크기가 작은 정수에 +를 쓴다
틀린 코드:
func nextStreak(_ current: Int8) -> Int8 {
return current + 10 // current 가 120 이면 실행 중 멈춘다
}
위 함수 문법은 함수를 다루는 장에서 배운다. 지금은 "값이 실행 중에 정해지는 경우 +가 범위를 넘으면 프로그램이 오류로 멈춘다"는 점만 보면 된다. 상수끼리 더하는 코드라면 컴파일러가 실행 전에 오류로 알려 주기도 한다.
고친 코드는 두 가지 방향이 있다. 넘칠 수 있는 값이라면 검사해서 처리한다.
let checked = streak.addingReportingOverflow(10)
// checked.overflow 가 true 인지 확인한 뒤 처리한다
애초에 연속 달성일처럼 커질 수 있는 값이라면 작은 타입 대신 기본 타입 Int를 쓰는 것이 가장 단순하다. 특별한 이유가 없다면 정수는 Int로 둔다.
한눈에 보기
| 주제 | 문법 | 핵심 규칙 |
|---|---|---|
| 상수 | let x = 1 | 기본 선택, 다시 대입하면 컴파일 오류 |
| 변수 | var x = 1 | 값이 바뀔 때만 사용, 안 바뀌면 경고 |
| 타입 명시 | let x: Double = 1 | 추론과 다른 타입을 원할 때 |
| 형 변환 | Double(n), Int(d) | 자동 변환 없음, Int()는 소수점을 버림 |
| 오버플로 검사 | a.addingReportingOverflow(b) | 값과 넘침 여부를 함께 돌려줌 |
| 튜플 | (name: "a", n: 1) | 이름으로 접근, let (a, b) = t로 분해 |
연습 문제
- 운동 습관의 하루 목표는 30분이고 오늘 20분을 했다. 목표와 한 시간은
let, 운동 시간은var로 두고 20분에 10분을 더 한 뒤의 달성률(퍼센트, 정수)을 출력하는 코드를 쓰시오. - 다음 코드에서 컴파일 오류가 나는 줄을 찾고 고치시오.
let pagesRead = 40 let pagesGoal = 100.0 let rate = pagesRead / pagesGoal UInt8은 0부터 255까지 담는 정수 타입이다.let days: UInt8 = 250일 때days.addingReportingOverflow(10)의overflow와partialValue를 예상하시오.- 독서 습관을
(title: "Swift 입문", pages: 120)튜플book으로 만들고, 제목만 출력하는 코드와 쪽수만 상수로 풀어 꺼내는 코드를 각각 쓰시오.
정답과 해설
- 정답 예:
출력은let goalMinutes = 30 var doneMinutes = 20 doneMinutes += 10 let percent = Int(Double(doneMinutes) / Double(goalMinutes) * 100) print("달성률: \(percent)%")달성률: 100%이다. 20분에 10분을 더하면 30분이라 비율이 1.0이다. 한 번이라도 바뀌는 것은doneMinutes뿐이므로 이것만var다. 나누기 전에 두 값을Double로 바꾼 점이 핵심이다. 문제에서 말한 "한 시간"은 쓰이지 않는 값이므로, 선언하면 오히려 쓰지 않는 상수가 생긴다. 이 정답에서는 만들지 않았다. - 오류가 나는 줄은 세 번째 줄
pagesRead / pagesGoal이다.Int와Double을 섞었기 때문이다.let rate = Double(pagesRead) / pagesGoal로 고치면 0.4가 된다. 250 + 10 = 260이고UInt8은 256개 값을 가지므로 한 바퀴 돌아overflow는true,partialValue는 4다.- 정답 예:
이름으로 접근하는 방법은let book = (title: "Swift 입문", pages: 120) print(book.title) let (_, pages) = book print(pages)book.title이고, 분해에서 쓰지 않는 요소는_로 비운다. 분해하는 변수 이름은 요소 이름과 같지 않아도 되지만, 읽기 쉽게 맞추는 편이 좋다.