본문 바로가기

Rust/pure

[Rust] 변수

변수의 선언

러스트의 변수는 let을 이용하여 선언한다. 대부분의 경우 각각의 타입을 명시해도 되고, 명시하지 않아도 된다.

이때 대부분의 프로그래밍 언어와는 달리 러스트에서의 변수는 기본적으로 "불변"의 성질을 띈다.

만약 변수를 가변적이도록 선언하고 싶다면, mut를 덧붙이면 된다.

 

let x:i32 = 5;
x = 14; // 안된다
let mut y:f64 = 17.3;
y = y + 12; // 불변형이 아니므로, 된다.

 

잘못 생각하면 let으로 선언한 변수를 타 언어의 const 변수, 즉 상수와 헷갈릴 수도 있는데, 이 것이 상수는 아니다.

보통 상수의 경우 값을 변경할 수 없으며, 재할당이 불가능한게 대부분이다(언어마다 차이가 있다).

러스트에서의 상수 역시 이러한 부분에서 let으로 선언한 변수와 큰 차이를 보인다.

상수는 const를 이용하여 선언한다.

 

const PI: f64 = 3.141592;
PI = 13; // 안된다. 상수는 값을 못바꾼다.
const PI: f32 = 3.1415; // 안된다. 상수는 재할당이 불가하다.

let number = " 145 ";
let number = number.trim().parse().expect("숫자로 파싱할 수 없음");
// number = 145 (i32)

 

상수와 let 변수의 가장 큰 차이는 재선언 가능 여부다. 상수는 당연하지만 재선언이 안된다. 변수의 경우 특이하게도 재선언이 가능한데, 동일 이름으로 다시 선언한다고 해서 타입이 동일해야 하는 등의 제약조건은 전혀 존재하지 않는다. 

 

이런 특성 덕분에 let 변수의 경우 변수 자체는 변환할 수 없도록 유지하여, 혹시 모를 문제 상황(가령, 실수로 10을 더하는 코드를 작성한다든지)을 배제하면서도, 동일 이름을 이용하여 직관적인 코드를 작성할 수 있다. 이런 특성을 러스트에서는 전 변수를 가린다고 하여 Shadowing 이라고 설명하고 있다.

 

use std::io;
...
let mut input: string = String::new(); // 버퍼 용도의 빈 문자열
io::stdin().readline(&mut input).expect("cannot read");

let input: i32 = input.trim().parse().expect("cannot parse string to number");
//input에 대한 shadowing. 동일 이름을 유지하여 코드의 직관성을 높일 수 있다.

 

위 코드에서 특징적인 부분은 input을 io::stdin.readline() 함수에 인자로 넘길 때 &mut를 붙였다는 점이다. 함수에 넘길 때 역시 변수는 기본적으로 불변이다. 따라서 mut를 붙여야 한다. & 을 설명하기 위해서는 러스트의 함수 동작 방식에 대해 이해해야 한다.

 

 러스트의 함수를 벗어날 때, 해당 함수 내부에서 사용되는 모든 변수는 기본적으로는 라이프타임이 종료되어 모두 할당 해제된다. 또한 함수에 인자를 넘길 때 해당 값이 기본형(흔히 아는 int, float 등)이 아닌 경우, 타 언어의 경우 보통 해당 인자의 레퍼런스 변수를 넘긴다 (자바 등의 언어에서 클래스 객체를 넘기는 경우를 생각해보자). 그러나 러스트의 경우 인자를 넘길 때 해당 인자에 대한 레퍼런스를 전달하는 대신, 해당 인자가 가진 소유권을 "이동" (move) 시킨다 (이후 소유권에 대한 문서도 공부할 예정이다.) .따라서, 해당 객체에 대한 소유권이 함수 내부의 레퍼런스 변수에게 이동하므로, 외부에서는 더 이상 해당 객체에 접근할 방법이 없다. 그 변수는 해당 함수의 종료와 함께 사라지는 것이다.

 

 이를 방지하기 위해서는 해당 객체 자체를 전달하는 대신, 객체에 대한 참조자(C++의 것과 유사)을 전달해야 한다. 이때 참조자를 전달하는 방법이 바로 &를 붙이는 것이다. 이렇게 되면 해당 변수에 대한 소유권은 넘어가지 않는다.

 

데이터 타입

러스트에서 설명하는 데이터 타입은 Scalar 타입과 Compound 타입이 있다. 이들은 러스트에서 제공하는 기본 자료형으로, string, vec 들과는 다르다. 여기에 속하는 값들은 함수로 넘길 때 복사된다는 특징이 있다.

 

Scalar 타입

다른 언어의 int, float, char 등에 대응된다. 이때, 64비트 기준으로 설명한다.

 

정수형

length Signed Unsigned equivalant
8-bit i8 u8 byte, char
16-bit i16 u16 short
32-bit i32 u32 int
64-bit i64 u64 long
128-bit i128 u128 보통 X
arch isize usize 컴퓨터 환경

러스트에서의 정수형은 다양한 크기로 존재한다. 이름이 상당히 직관적인데, signed 여부 + 타입 크기의 조합을 이룬다.

arch의 경우 해당 운영체제의 비트 수에 해당하는 사이즈를 가진다. 64비트 환경에서 arch의 크기는 64-bit이다.

정수형에서 기본이 되는 자료형은 i32이며, 다음과 같은 방식으로 숫자를 나타낼 수 있다.

  • 정수 : 10_000, 1_852_470 ... 3자리 단위로 언더바를 이용하여 표현 가능.
  • hex : 0x1F, 0x52 ... 0x으로 시작.
  • oct : 0o72, 0o446 ... 0o으로 시작.
  • bin : 0b10001010 ... 0b로 시작.
  • byte(u8만 해당) : b'A' ... b로 시작. 해당 문자의 아스키 코드를 의미

보통 정수형을 설명할 때 overflow에 대해 설명한다. 특정 정수형이 표현할 수 있는 수의 범위를 넘어서는 경우, 의도한 숫자가 아닌 다른 숫자가 나오는 것이 overflow이다. 프로그래밍 언어마다 처리 방식이 상당히 다른데, 러스트의 경우 디버그 모드에서는 오버플로우를 기본적으로 허용하지 않는다. 대부분의 경우 오버플로우는 프로그래머가 의도한 것이 아니기 때문이다. 디버그 모드와는 달리 릴리즈 모드의 경우 오버플로우 발생시 2의 보수(two's complement wrapping으로 설명하고 있음)처리한다. 프로그래머가 오버플로우를 의도했거나, 변수의 연산에 대해 오버플로우를 직접 다루고 싶은 경우에 대비하여 러스트에서는 여러가지 내장 함수를 갖추고 있다.

 

  • wrapping_* : 기본적인 오버플로우. 이 함수를 이용하면 디버그 모드에서도 panic처리하지 않는다.
  • checked_* : 오버플로우가 발생하면 None을 반환한다(Result 타입을 반환하는데, 러스트의 특징 중 하나).
  • overflowing_* : 오버플로우 발생 여부를 변수와 함께 반환한다. (value, bool)의 튜플 형태로 받는다.
  • saturating_* : 연산을 해당 타입의 범위 내에서만 수행한다(최대/최소값을 넘는 값은 최대/최소값으로 고정) 

 

let y: u8 = 16;
let k: u8 = y.checked_add(13).expect("overflowed value!"); // match를 통해서도 분기 가능

let z: u8 = 253;
let (z, b) = z.overflowing_add(17); //오버플로우가 가능하다면, overflowing_*로 명시하는게 좋음.
println!("{} {}", z, b); // 14 true

let z = z.saturating_mul(40); // 255
let z = z.wrapping_add(1); // 0

 

 

실수형

length type equivalent
32-bit f32 float
64-bit f64 double

실수형 역시 이름이 상당히 직관적이다. f + 자료형 크기 의 구조를 이룬다.

의외로 기본 자료형은 f64인데, 최근 CPU들이 f32나 f64나 처리 속도가 비슷하여 f64를 채택했다고 한다.

IEEE-754에 따라 구현된다.

 

 

이외의 자료형

type value equivalent
bool true / false 불린 자료형
char UTF-8 문자 문자"만"
의미하는 경우

정수, 실수 이외의 자료형에는 bool, char이 있다.

bool은 그냥 다른 언어와 별반 다를 바 없는데, 자바처럼 조건 / 반복문에는 bool 타입만 올 수 있다는 특징이 있다.

 

char은 다른 언어들과 "상당히" 다르게 다가온다. 러스트에서의 char은 숫자의 i8이나 u8과 명확히 구분되며, C나 C++처럼 ASCII 기반이 아니라 UTF-8 기반이다. 이러한 이유로 char 타입의 크기는 통상적으로 할당되는 1byte가 아닌 4bytes 크기를 가진다. 

또한, char 타입이 모인다고 문자열을 만들 수 없다. 다음 코드를 보자.

// 자바스크립트 코드
let arr = ['a', 'b', 'c', 'd', 'e'];
let my_string = arr.join();
// expect "abcde"

 

자바스크립트를 사용한다면, 위 코드는 너무나도 자연스럽다. 당연히, my_string은 "abcde"가 될 것이다.

마침 당신은 러스트에서도 join 함수를 발견하여, 이를 사용하려고 한다. 이 경우, 러스트는 오류 메시지를 제공한다.

오류를 알려준다.
오류 메시지

 무엇이 문제일까? 우선, 러스트의 join 메서드는 문자열을 합치는 것 이외에 동일 타입에 대한 배열을 합치는 역할을 수행한다. 만약 문자열을 합성하기 위해서는 문자열로만 구성된 배열에 대해 선언해야 한다. 따지고 보면 자바스크립트의 경우 문자 타입이 따로 없기 때문에 선언된 모든 값이 string 타입이라고 볼 수 있지만, 문자열이 아니더라도 join이 가능한 유연성을 보여주는데 비해, 러스트는 타입을 중시하는 언어이므로, 얄짤없이 다른 방법을 찾아야 한다.

 러스트에서 char 배열을 이용하여 string을 만드는 방법 자체는 다양하나, 주로 사용하는 방법을 적겠다.

let arr : [char; 5] = ['a', 'b', 'c', 'd', 'e'];

//1. arr을 iter로 전개 후 collect로 모아 "String"으로 타입 명시
let str1: String = arr.iter().collect();
//2. String::from_iter에 iter 특성 가진 배열 등 넘김 
let str2: String = String::from_iter(arr);

이때 collect 함수는 명시하는 타입에 따라 다른 값을 반환하므로, String 타입을 원하면 이를 명시해야 한다.

 

Compound 타입

많은 값에 대해 일종의 컨테이너 역할을 하는 타입. 선언한 이후에는 크기를 바꿀 수 없다.

해당 타입에는 튜플, 배열이 있다.

튜플(tuple)

다양한 유형의 값을 하나로 묶는 방식의 타입으로, 선언 이후 고정된 크기를 변경할 수 없다.

let tup: (i32, i64, u8) = (1, 1.4, 5); // 튜플 생성

let (x, y, z) = tup; // 튜플의 분해(destructing)

let a = tup.0; // 인덱스 기반으로 접근도 가능.

 

배열(array)

동일 타입에 대해 고정된 수의 요소를 가지는 타입으로, 값은 힙이 아닌 스택에 저장된다. 기본적으로 전통적인 배열과 유사한 지위를 가진다. C, C++ 등의 언어에서 사용하는 것과 비슷하다.

let arr = [1,2,3,4,5]; // 타입을 지정하지 않아도 상관은 없다.
let arr2 : [i32;3] = [10,20,30]; // 타입을 지정하는 경우, [type; length] 형태
let arr3 = [0; 5]; // 길이가 5이고, 값이 0인 배열을 생성.

기본적으로 인덱스 기반으로 작동하며, 인덱스를 통해 해당 값에 대해 연산할 수 있다. 또한 할당되지 않은 영역에 접근하는 경우 런타임에 해당 행위를 검사한 후, panic ( 타 언어의 error이나 exception에 대응 ) 을 발생, 프로그램을 종료한다. 따라서, C 언어가 잘못된 인덱스에 접근할 수 있는 것과는 달리 러스트는 이런 인덱스에 접근할 수 없다.

'Rust > pure' 카테고리의 다른 글

[rust] 숫자 parse 할 때 주의점  (0) 2023.04.04
[rust] 문자열 문자 단위로 나누기  (0) 2023.03.26
[Rust] 함수(function)  (0) 2021.11.08