Rust 변수와 가변성

zdpk·2024년 6월 25일

Rust

목록 보기
3/4
post-thumbnail

프로그래밍에서 가장 기본적인 요소인 변수부터 살펴보겠다.

먼저 프로젝트를 생성하자.

$ cargo new variable_and_mutability

대다수의 프로그래밍 언어와 마찬가지로, 프로그램의 시작점인 Entry Point는 main 함수다.


Immutable Variable

변수 선언은 let으로 한다.

JS, Swift 등과 동일하다.

fn main() {
	let a = 1;
 	
    println!("{a}");
}

특이한 점은 Rust는 기본적으로 모든 변수가 '불변'이라는 것이다.

고로 다음 코드는 컴파일 오류가 발생한다.

fn main() {
	let a = 1;
    // ❌ cannot mutate immutable variable `a`
 	a += 1;
    println!("{a}");
}

대부분의 프로그래밍 언어는 이와 정반대로 설계되어 있다.

기본이 가변 변수인 것이다.

변수라는 이름 자체가 '변할 변'에서 유래 되었고,

variable도 마찬가지로 '변할 수 있다'라는 의미를 지니기 때문에 '불변 변수'라는 말 자체가 어색하게 느껴질 수도 있다.

그러나 소프트웨어 공학에 수십 년 간 몸을 담았던 사람들이 발견한 공통 패턴은, 마치 파레토의 법칙처럼 가변 변수와 불변 변수의 비율이 20:80 인지까지는 모르겠지만,

어쨌든 불변 변수의 사용 빈도가 훨씬 더 많았다는 사실이다.

심지어 가변 변수를 사용함으로 인해, 변하면 안 되는 부분까지도 변경 해버려서 버그가 발생하는 일도 잦았기 때문에 '변경'할 자유보다는 제한을 둠으로서 얻는 실익이 더 크다는 것을 인지하게 되었다.

경험적으로 옳은 판단들이 현대 프로그래밍 언어인 Rust에 영향을 미치게 되었고, 기본 동작이 '불변'이 된 것이다.

덕분에 불변 변수를 변경하려고 하면 컴파일 오류가 발생하여 코드를 수정하는 사람이 한 번 더 확인하게 된다.

의도적으로 불변 변수인 것인지, 아니면 가변 변수로 바꾸는 선택의 기로에 놓인 것인지 말이다.

가변 변수였다면 아무런 경고도 주지 않아서 그냥 넘어갔을 것이고, 이것이 변경해서는 안 되는 변수였다면 버그가 될 수 있었을 것이다.

기본 동작을 반전시킨 것만으로 상당히 많은 실수를 줄여줄 수 있게 되었다.

Rust는 이렇게 과거의 실수들을 교훈 삼아 최대한 올바르고 안전한 언어 디자인을 추구하였다.


Mutable Variable

불변 변수만 존재해서는 제대로 된 프로그램을 만들 수 없다.

볼륨을 조절하기 위해서는 가변 변수가 필요하다.

자동차의 속력을 조절하기 위해서도 가변 변수가 필요하다.

가변 변수가 불변 변수보다 덜 사용 된다지만 프로그램에 없어서는 안 되는 존재다.

Rust에도 당연히 존재하며, 불변 변수에 mut 키워드만 추가하면 된다.

fn main() {
	let mut a = 1;
    a += 1;
    println!("{a}");
}

let, mut가 둘 다 붙는 것이 장황해 보일 수도 있지만,

변수 선언은 불변, 가변 상관 없이let을 통해 일관적으로 선언하고, 불변 변수에 '가변성'을 부여한다고 생각하면 나름 합리적이다.


Scalar Types

Scalar Type은 8byte 이하의 Stack 상에 표현 가능한 가장 기본적인 데이터 타입들을 일컫는다.

다른 언어에서는 Primitive Type이라 부르기도 한다.


Integer Types

Rust의 정수 타입은 int, unsigned int로 나뉜다.

inti8, i16, i32와 같이 i + bit 수로 표현하고,

unsinged intu8, u16,u32가 된다.

최소 i8부터 최대 i128까지 존재한다.

정수 리터럴로 초기화 시, 기본적으로 i32 타입이 된다.

fn main() {
	let a = 1; // i32
}

Integer literal expressions

fn main() {
	let a = 1u8; // u8
    let b = 1i64; // i64
}

위와 같이 정수 리터럴 뒤에 타입을 명시하면 i32가 아닌 원하는 타입으로 초기화가 가능하다.

이를 Integer literal expression이라 부른다.

https://doc.rust-lang.org/reference/expressions/literal-expr.html#integer-literal-expressions

정수 리터럴 중간에 _을 임의로 삽입할 수 있다.

가독성을 위한 것이기 때문에 실제 값에는 영향을 주지 않는다.

fn main() {
	let a = 1_u8; // 1u8과 동일
    let b = 1_000; // 1000과 동일
}

요즘 언어에는 대부분 지원되는 기능인데, 가독성 높이는데 유용하다.


usize, isize

다른 정수 타입의 크기가 architecture에 상관 없이 고정 크기라면,

fn main() {
	let a = 1_usize; // 64bit usize
	let b = 1_isize; // 64bit isize
}

isize, usize는 32bit CPU에서는 32bit가 되고, 64bit CPU에서는 64bit가 된다.

주로 배열 index나 길이, pointer size 등으로 사용된다.

공식 문서에는 The pointer-sized unsigned integer type이라 되어 있다.

https://doc.rust-lang.org/std/primitive.usize.html

배열의 길이도, 포인터 주소도 음수가 될 수 없기 때문에 대체로 usized가 사용된다.


Floating Point Type

64bit, 32bit 부동소수점 타입이 존재한다.

여타 언어와 마찬가지로 IEEE 754 표준을 따르며,

각 타입은 f64, f32로 표기한다.

fn main() {
	let a = 1.0; // f64
    let b = 1.; // f64
    let c = 1.0_f32 // f32
    // ❌ `{integer}` is a primitive type and therefore doesn't have fields
	let c = 1._f32 // f32
}

32bit는 정밀도가 떨어져서 거의 사용되지 않기 떄문에 부동소수점 리터럴 초기화 시, 기본적으로 f64가 된다.

소수점 뒤가 전부 0이라면 1.과 같이 표기할 수도 있다.

literal expression도 사용 가능하다.

1.0f32, 1.0_f64와 같이 말이다.

그러나 1._f32처럼 . 뒤에 _가 바로 이어서 나오는 것은 컴파일 오류로 간주한다.


bool

fn main() {
	let a = true; // bool
    let b = false // bool
}

다른 언어와 마찬가지로 boolean 타입 리터럴은 true, false이 존재하며, 짧게 bool이라 한다.


char

fn main() {
	let a = 'x';
    let b = '🦀';
}

문자 타입은 char이라 하며, 홑따옴표로 감싸서 표현한다.

Unicode로 전세계 모든 문자를 표현할 수 있으며, 4byte 크기다.

fn main() {
	// ❌ if you meant to write a `str` literal, use double quotes: `"xx"`
	let a = 'xx';
    // ❌ Syntax Error: empty character literal
    let b = '';
}

하나의 문자만 나타내므로, 반드시 1글자여야 한다. 빈 문자도 안 된다.

컴파일 오류 난다.


Unit Type

C, Java 등에서는 반환값이 없으면 void로 표기하지만,

Rust는 ()로 표기한다.

Unit Type이라고도 부른다.

void 처럼 값이 없는 것이 아니라, '없음'을 표현하는 하나의 값이라고 생각하면 된다.

수학에서 모든 함수는 N개의 입력과 1개의 출력을 가지는데,

Rust는 이 개념을 언어 차원에 적용한 것으로 보이며, 함수는 반환이 없는 경우도 '없음'이라는 값을 반환함을 통해서 언제나 1개의 값을 반환하는 일관적인 구조가 된다.

참고로 지금까지 fn main은 반환 타입을 명시하지 않았었는데,

반환 타입을 명시하지 않는 경우 자동으로 Unit Type()을 반환하는 것과 동치가 된다.

fn main() {
	let a = (); // Unit Type
}

Type Inference

C, Java 등은 변수명 앞에 타입을 명시하도록 되어 있다.

// C
int a = 1;

이러한 선행 타입() 구조를 갖기 때문에 반드시 타입 표기가 수반되어야 하며, 가독성이 떨어진다.

int a = 1을 문장처럼 읽으면 int a is 1이 되고,

let a = 1let a 1이 된다.

두 번째 문장이 보다 자연스럽다.

또한 후행 타입은 타입 추론(Type Inference)에 보다 적합하다.

fn main() {
	let a = 1; // 자동으로 i32 타입으로 추론
}

위 코드에서 a는 정수 리터럴을 사용했기 때문에 자동으로 i32 타입으로 추론된다.

즉, 타입을 명시할 필요가 없다.

fn main() {
	let a = true; // 자동으로 bool 타입으로 추론
	let b = 1.0; // 자동으로 f64 타입으로 추론
}

Rust는 강력한 타입 시스템을 가지고 있기 때문에 대부분의 경우 타입을 명시할 필요가 없다.

다음과 같이 매우 복잡한 타입들을 사용하는 경우를 생각해보자.

RefCell<SparseSecondaryMap<NodeId, Vec<Box<dyn FnOnce()>>>

fn main() {
	// 실제로 컴파일 X
	RefCell<SparseSecondaryMap<NodeId, Vec<Box<dyn FnOnce()>>> a = 
    	RefCell::new(SparseSecondaryMap<NodeId, Vec<Box<dyn FnOnce()>>>::new());
}

선행 타입이었다면 타입을 쓸 데 없이 중복해서 두 번이나 명시해야 하는데, 타입이 길어서 잘 보이지도 않는다.

fn main() {
	// 자동 추론
	let a = RefCell::new(SparseSecondaryMap<NodeId, Vec<Box<dyn FnOnce()>>>::new());
}

Rust는 후행 타입이기 때문에 타입을 명시할 필요가 없다.

복잡한 타입을 정확히 기억할 필요가 줄어 든다는 것도 장점이다.

some_mod::get이라는 함수가 RefCell<SparseSecondaryMap<NodeId, Vec<Box<dyn FnOnce()>>>를 반환한다고 가정하자.

fn main() {
	RefCell<SparseSecondaryMap<NodeId, Vec<Box<dyn FnOnce()>>> a = some_mod::get();
}

선행 타입이라면 위와 같이 모든 타입을 명시해야 한다.

즉, 복잡한 타입을 찾아서 명시하거나 기억해야 한다.

fn main() {
	let a = some_mod::get();
}

후행 타입이라면 알아서 추론이 되기 때문에 명시할 필요가 없고, Rust Analyzer에 의해 실제 타입이 VSCode에 자동으로 명시될 것이다.

개발자는 그냥 타입을 읽기만 하면 된다.

이렇듯 후행 타입은 가독성 향상 및 타입 추론과 여러모로 잘 맞는다는 장점이 있다.

이러한 연유로 Rust, Kotlin, TS 등 최근 나온 언어들은 대부분 후행 타입을 채택하였다.


Type Annotation

그러나 타입을 명시해야 할 때도 있다.

Vec는 Java의 List, JS의 Array와 유사한 동적 배열인데, 초기화 시에 요소를 하나도 삽입하지 않는 경우 어떤 타입에 대한 Vec인지 추론이 불가하다.

fn main() {
	let vec = Vec::new(); 
}

고로 Vec<{unknown}> 타입이 된다.

Rust Compiler가 워낙 똑똑하기 때문에 삽입을 늦게 해도 웬만하면 추론이 되지만, 정말 안 되는 경우는 다음과 같이 타입을 명시해줄 수 있다.

fn main() {
	// i32 요소가 들어갈 수 있는 Vec
	let vec: Vec<i32> = Vec::new(); 
}

Vec는 Scalar Type이 아니므로 추후 자세히 살펴보도록 하겠다.

아무튼 이러한 연유로 Type Annotation이 필요할 때도 가끔 있으며, 필요 없을 때도 명시할 수는 있다.

fn main() {
    let a = 1; // i32
	let b: u8 = 1; // u8로 타입 명시
    let c = 1_u8; // u8 integer literal expression
}

Integer Literal은 기본적으로 i32 타입이 되기 때문에 integer literal expression을 통해 u8 등의 다른 타입을 지정해 줄 수 있었는데, 이는 Type Annotation으로도 가능하다.

가독성 면에서는 Type Annotation이 더 깔끔해 보인다.


variable shadowing

Rust에는 Shadowing이라는 개념이 존재한다.

동일한 이름의 변수를 재선언 하면 이전 변수가 덮히는 개념인데

24년 6월 기준 Go나 Java에서는 지원되지 않는다.

// go
func main() {
    var a = 1
    // ❌ a redeclared in this block
    var a = 2
	fmt.Println(a)
}
// java
public class Program {
    public static void main(String[] args) {
        String a = "1";
        // ❌ error: variable a is already defined 
        String a = "2";
	}
}

이렇게 오류가 나는 것을 볼 수 있다.

// rust
fn main() {
	let a = 1;
    let a = 2; // 여기서 원래 `a`를 덮어씀
    println!("{a}"); // 2
}

위처럼 Rust에서는 Shadowing이 가능하다.

이런 기능이 있고 없고가 언어의 우월성을 가리는 것은 아니고, 언어의 특성에 따라서는 오히려 없는 편이 나을 수도 있다.

장단점을 간단히 살펴보기 위해 예시를 몇 개 살펴보자.

fn main() {
	let grade = "100";
    // 문자열을 숫자로 변환
    let parsed_grade = grade.parse().unwrap();
}

간단한 문자열 변환 코드인데, Shadowing이 안 된다면 다른 변수명을 사용해야 한다.

이럴 때는 이름 짓는 것이 여간 귀찮은 것이 아니다.

fn main() {
	let grade = "100";
    // 문자열을 숫자로 변환
    let grade = grade.parse().unwrap();
}

어차피 타입만 다를 뿐, 같은 데이터를 가리키고 있기 때문에 변수명이 같아도 이해하는데 전혀 문제가 되지 않는다.

Shadowing을 사용하면 이런 상황에서 이름 때문에 골머리를 썩힐 일이 줄어든다.

다음 예시는 소유권이 들어가기 때문에 지금은 잘 이해가 되지 않을 수도 있으니 이해가 잘 안되면 넘어갈 생각으로 편하게 살펴보자.

// `main`에 선언된 `name`의 소유권이
// `decorated_name` 함수의 매개 변수인 `name`으로 이동
fn decorated_name(name: String) -> String {
	// `name`에 "Super"을 붙여서 소유권을 다시 `main`에게 반환
	format!("Super {}", name)
}

fn main() {
    let name = String::from("Rust"); // "Rust"
    let name = decorated_name(name); // "Super Rust"
}

Rust는 모든 곳에 소유권이 존재하기 때문에, 이렇게 name의 소유권을 decorated_name에게 넘겨서 작업한 뒤, 다시 main의 2번째 name에 소유권을 받아와서 저장하는 방식으로 사용할 수 있다.

만약 Java 였다면 소유권이 없기 때문에 name을 재활용 할 수 있지만 Rust는 이것이 불가하다.

// java
public class Program {
    public static String decoratedName(String name) {        
        return "Super " + name;
    }

    public static void main(String[] args) {
        String name = "Rust"; // "Rust"
        name = decoratedName(name); // "Super Rust"
        
        System.out.println(name); // 결과 출력
    }
}

그래서 구조적인 이유로 소유권을 넘겼다 받았다 하는 일이 잦다.

기존 name이 살짝 꾸며졌을 뿐, 타입도 같고, 별개의 존재가 된 것이 아니기 때문에 변수명을 굳이 새로 짓기는 너무 귀찮다. 심지어 타입도 String으로 동일하다.

만약 Shadowing이 안 된다면, decorate되면 decorated_name, process 되면 processed_name, handle되면 handled name과 같이 쓸 데 없이 변수명을 바꿔야 해서 길어지고 복잡해지는 경우가 많을 것이다.

이러한 연유로 Shadowing은 Rust에서 꽤나 유용하다.

0개의 댓글