2. 변수, 타입과 스코프

지역 변수, 정수 범위, bool과 스코프를 배웁니다.

Wave Foundation

값을 이름으로 다루기

가격을 여러 곳에 직접 적으면 가격을 바꿀 때 모든 위치를 찾아야 합니다. 변수는 값에 이름을 붙이고 그 이름을 통해 읽고 변경하는 저장 공간입니다. 이번 장에서는 선언, 대입, 타입의 범위와 블록 안에서 이름이 보이는 범위를 배웁니다.

아래 프로그램들은 각각 별도의 main.wave 전체입니다. 예제 하나를 저장한 뒤 wavec run main.wave로 실행하고 다음 예제로 교체하십시오.

선언과 초기화

fun main() {
    var price: i32 = 1200;
    var quantity: i32 = 3;
    println("price={}", price);
    println("quantity={}", quantity);
    println("total={}", price * quantity);
}

실행 결과:

price=1200
quantity=3
total=3600

선언을 네 부분으로 나눠 읽습니다.

부분 이 예제 역할
선언 키워드 var 지역 변수 생성
이름 price 이후 사용할 식별자
타입 i32 저장할 값의 종류와 범위
초기값 1200 처음 저장할 값

타입 앞의 콜론과 초기값 앞의 등호는 역할이 다릅니다. 이름에는 의미를 담으십시오. 이 예제에서 price는 단가이고 quantity는 개수입니다. 같은 i32라도 서로 바꿔 사용하면 문법 오류 없이 잘못된 계산을 할 수 있습니다.

대입은 관계를 유지하는 수식이 아님

변수에 계산 결과를 저장하면 그 시점의 값이 들어갑니다. 계산식을 기억해서 나중에 자동으로 다시 평가하지 않습니다.

fun main() {
    var price: i32 = 1200;
    var quantity: i32 = 3;
    var total: i32 = price * quantity;
    quantity = 5;
    println("before recalculation={}", total);
    total = price * quantity;
    println("after recalculation={}", total);
}

실행 결과:

before recalculation=3600
after recalculation=6000

quantity = 5는 이미 존재하는 변수에 새 값을 씁니다. var quantity처럼 다시 선언하는 것과 구분해야 합니다. total도 다시 대입하기 전에는 3600입니다. 여러 변수 사이의 관계를 프로그램이 유지해야 한다면 관계가 바뀔 때 계산을 수행하도록 작성해야 합니다.

이전 값으로 다음 값 계산하기

대입문의 오른쪽을 먼저 계산하고 그 결과를 왼쪽 저장 공간에 씁니다. 수학의 등식과 달리 count = count + 1은 유효한 갱신입니다.

fun main() {
    var count: i32 = 0;
    count = count + 1;
    count += 2;
    count *= 3;
    println("{}", count);
}

실행 결과:

9

값의 진행은 0 → 1 → 3 → 9입니다. +=, *=는 계산과 저장을 함께 표현합니다. 계산 순서를 따라 적어 보면 결과가 예상과 다를 때 어느 단계에서 생각이 달랐는지 찾을 수 있습니다.

정수 타입의 폭과 부호

i로 시작하면 signed, u로 시작하면 unsigned 정수입니다. 뒤 숫자는 비트 수입니다. 비트 수가 커지면 표현할 수 있는 범위가 늘어나고 저장 공간도 늘어납니다.

타입 최솟값 최댓값 예시 용도
i8 -128 127 작은 부호 있는 값
u8 0 255 한 바이트
i16 -32768 32767 작은 정수 데이터
u16 0 65535 포트·16비트 필드
i32 -2147483648 2147483647 일반적인 작은 정수 계산
u32 0 4294967295 32비트 비트 필드

64·128·256·512·1024비트 signed/unsigned 정수도 있습니다. “넓은 정수가 있으니 아무 계산이나 안전하다”는 뜻은 아닙니다. 선택한 폭에서 계산 범위를 넘을 수 있으므로 필요한 범위를 먼저 생각합니다. isz와 usz는 컴파일 대상의 주소 크기를 따릅니다.

큰 리터럴을 작은 타입에 저장하는 것과 의도적으로 cast해서 비트를 버리는 것은 구분해야 합니다. 단순히 오류를 없애려고 작은 타입으로 변환하면 값 자체가 바뀔 수 있습니다. 변환은 다음 장에서 다룹니다.

실수와 bool

f32·f64는 부동소수점 수입니다. 정수와 달리 소수부를 표현할 수 있지만 모든 십진수를 정확히 저장하지는 못합니다. 금액을 작은 정수 단위로 관리하는 이유 중 하나입니다.

bool은 참과 거짓을 나타냅니다. 다음처럼 비교 결과를 저장하면 조건에 이름을 붙일 수 있습니다.

fun main() {
    var balance: i32 = 5000;
    var cost: i32 = 3600;
    var can_buy: bool = balance >= cost;
    if (can_buy) {
        println("purchase allowed");
    } else {
        println("not enough money");
    }
}

실행 결과:

purchase allowed

can_buy가 balance와 cost의 변화를 자동으로 따라가는 것도 아닙니다. 두 값이 바뀐 뒤 현재 상태가 필요하면 비교를 다시 수행합니다.

초기화하지 않은 저장 공간

var value: i32;는 저장 공간만 선언하는 형태입니다. 읽기 전에 유효한 값을 써야 합니다. 선언했다고 자동으로 0이 들어간다고 가정하지 마십시오. 입문 과정에서는 값을 바로 알 수 있으면 선언과 동시에 초기화하는 편이 이해하기 쉽습니다.

출력 인자로 값을 받는 라이브러리 호출에서는 먼저 공간을 선언한 뒤 성공했을 때 읽는 경우도 있습니다. 그때는 함수의 성공 결과를 확인해야 합니다. 실패한 호출 뒤 초기화되지 않은 출력값을 읽는 실수를 피하십시오.

블록과 이름의 유효 범위

블록은 중괄호로 묶은 코드 영역입니다. 안쪽에서 같은 이름의 변수를 새로 선언하면 해당 블록 안에서는 새 변수가 사용됩니다. 이를 shadowing이라고 합니다.

fun main() {
    var value: i32 = 10;

    if (true) {
        var value: i32 = value + 5;
        println("inner={}", value);
        value += 1;
        println("inner changed={}", value);
    }

    println("outer={}", value);
}

실행 결과:

inner=15
inner changed=16
outer=10

안쪽 선언의 초기식 value + 5는 바깥 value를 읽습니다. 새 변수의 초기화가 끝난 뒤 안쪽 value는 15입니다. 안쪽 값을 16으로 바꾸어도 바깥 저장 공간은 바뀌지 않습니다. 블록이 끝나면 다시 바깥 value가 보입니다.

반대로 안쪽 블록에서 var 없이 value += 1만 실행하면 보이는 기존 변수를 변경합니다. 새 선언인지 기존 값의 변경인지 키워드를 보고 구분하십시오.

지역 변수와 최상위 저장소

함수 밖에서는 const와 static을 사용할 수 있습니다. const는 상수 값을 표현하고 static은 실행 동안 유지되는 저장 공간입니다. 지역 변수와 같은 방식으로 모든 곳에서 선언할 수 있는 것은 아닙니다.

const LIMIT: i32 = 3;
static visits: i32 = 0;

fun visit() {
    visits += 1;
    println("visit={}", visits);
}

fun main() {
    visit();
    visit();
    println("limit={}", LIMIT);
}

실행 결과:

visit=1
visit=2
limit=3

visit를 두 번 호출해도 static은 호출마다 새로 0이 되지 않습니다. 반면 함수 안에서 var visits: i32 = 0;으로 선언하면 호출할 때마다 지역 저장 공간을 초기화합니다. 공유되는 변경 가능한 상태는 동작을 추적하기 어렵게 만들 수 있으므로 함수의 입력·출력으로 해결할 수 있는지 먼저 생각합니다.

흔한 실수

  • 선언과 대입을 혼동해 같은 이름을 불필요하게 다시 선언하는 경우.
  • 계산 결과를 저장한 변수가 입력 변수의 변경을 자동으로 따라간다고 생각하는 경우.
  • 타입이 같으니 개수와 바이트 수 같은 단위도 같다고 생각하는 경우.
  • 초기화 없이 읽거나 함수가 실패했는데 출력 인자를 읽는 경우.
  • 지역 변수의 이름이 블록 밖에서도 보인다고 생각하는 경우.

오류가 난 이름을 찾을 때는 그 이름의 선언 위치와 중괄호 범위를 함께 확인합니다.

연습: 재고 변경 계산

초기 재고는 20개이고 3개씩 두 번 판매합니다. 남은 재고와 판매한 전체 개수를 출력하십시오. 재고를 바꿀 때마다 같은 변수를 갱신하고 판매량도 별도로 누적합니다.

전체 풀이

fun main() {
    var stock: i32 = 20;
    var sold: i32 = 0;
    var order: i32 = 3;
    stock -= order;
    sold += order;
    stock -= order;
    sold += order;
    println("stock={} sold={}", stock, sold);
}

실행 결과:

stock=14 sold=6

재고와 판매량이 함께 바뀌어야 합니다. 둘 중 하나만 갱신하면 값 사이의 관계가 깨집니다. 반복되는 주문 처리는 함수와 반복문을 배우면서 묶어 보겠습니다.

정수와 부동소수점 타입

정수 타입은 다음과 같습니다.

  • 부호 있음: i8, i16, i32, i64, i128, i256, i512, i1024
  • 부호 없음: u8, u16, u32, u64, u128, u256, u512, u1024
  • 주소 크기 정수: isz, usz
  • 부동소수점: f32, f64

isz는 주소 크기에 맞는 부호 있는 정수 타입이고, usz는 주소 크기에 맞는 부호 없는 정수 타입입니다.

기타 내장 타입

타입 용도
bool true 또는 false
char 부호 없는 8비트 문자 값. 임의의 Unicode 코드 포인트 타입이 아님
byte 8비트 바이트 값
str NUL로 끝나는 문자열 바이트열
ptr<T> T를 대상으로 하는 포인터
array<T, N> 요소 타입 T, 길이 N인 고정 길이 배열

사용자 정의 구조체와 열거형, 타입 별칭도 타입 위치에 사용할 수 있습니다.

var는 지역 변수를 선언하는 문법입니다. 타입 별칭은 같은 타입을 코드의 문맥에 맞는 이름으로 표현하는 문법입니다.