Rust (프로그래밍 언어)/문법: 두 판 사이의 차이
잔글 →문자열의 처리 |
잔글 →예외 처리 |
||
| 290번째 줄: | 290번째 줄: | ||
== 예외 처리 == | == 예외 처리 == | ||
회복 불가 타입의 에러는 panic!("에러 메세지") 등으로 에러 메세지를 출력하고 프로그램을 중단할 수 있으며, 회복 가능한 에러(예: 파일 읽기 실패 등)의 경우 사전 정의된 Result 열거형(Ok와 Err 두개의 값을 가진다)를 활용해 match로 에러가 날 법한 결과물을 조사(예: File::open(...)을 시도한 결과물을 match)할 수 있다. 덤으로 Result 열거형으로 에러가 걸렸을 때 호출한 함수에서 호출한 쪽으로 Result 열거형의 값을 반환해 오류를 Propagate할 수 있다. | |||
unwrap() 메서드를 사용하면 Result의 Err를 감지하여 자동으로 panic!을 호출하게 할 수 있다. expect(error_message)로 실패 가능성이 있는 기능 호출에 붙여서 디버깅 용도로 쓸 수 있으며, Rust는 컴파일러 단계에서 이걸 놓친 부분에 Warning을 통해 프로그래머가 디버깅을 하기 쉽도록 유도한다. | |||
== Generic == | == Generic == | ||
2023년 7월 24일 (월) 16:09 판
이 문서에서는 Rust 프로그래밍 언어로 프로그램을 개발하기 위한 프로세스와 언어 문법을 다룬다.
일단 Rust는 객체지향 프로그래밍(Object Oriented Programming)을 목적으로 만든 언어가 아니다. 따라서 C언어의 class 관련 기능은 물론이고, 캡슐화/다형성/상속에 직접 대응하는 기능이 언어 차원에서는 없다. Rust의 대표 특징 중 하나인 Trait 개념은 오히려 Javascript의 Prototype에 가까운 형태이나, Rust의 등호 기호(=)의 동작이 복사가 아닌 단방향 데이터 이동임을 생각하면 Trait는 Prototype과는 또 다르며, C++ 언어의 인터페이스와도 유사한 측면이 있는 등 Rust만의 독특한 특징으로 꼽힌다.
Cargo 기반 개발 환경 세팅 및 프로그램 기본 구조
물론 rustc로 직접 rs 파일을 컴파일할 수 있지만, 보통은 Rust의 공식 패키지 매니저인 cargo로 프로젝트를 생성한다.
cargo 사용법과 toml 파일
먼저 cargo new (프로젝트 명칭)으로 프로젝트를 생성한다.
프로젝트를 테스트할 때에는 cargo run으로 컴파일 및 실행을 동시에 하며, 배포할 때는 cargo build를 사용한다.
외부 라이브러리를 가져올 때는 cargo add (패키지 명칭)로 (프로젝트 명칭).toml 파일에 라이브러리 의존성을 추가한다.
rs 파일의 기본 구조
use std::time::{Duration, SystemTime};
use std::thread::sleep;
fn main() {
let now = SystemTime::now();
// we sleep for 2 seconds
sleep(Duration::new(2, 0));
match now.elapsed() {
Ok(elapsed) => {
// it prints '2'
println!("{}", elapsed.as_secs());
}
Err(e) => {
// an error occurred!
println!("Error: {e:?}");
}
}
}위 코드는 Rust의 SystemTime 구조체 예제에서 가져왔다. main 함수를 정의하면 거기가 프로그램의 진입점이 되고, use로 C++언어의 #include 전처리기와 네임스페이스 처리와 같은 기능을 동시에 수행한다.
참고로 내장 함수는 이름 끝에 !에 붙어 있으며, println!으로 문자열을 한 줄 출력하는 함수 등이 있다.
한 줄 주석은 C언어처럼 // 뒤에 넣는다.
변수와 상수, 기본 연산
다음 접두어를 붙여 변수나 상수 등을 정의할 수 있다.
- const: 상수, 처음 정의한 뒤에 그 값은 물론이고, Trait로 가져온 메서드로도 내부의 데이터를 바꾸지 못한다.
- static: 프로그램이 끝날 때까지 남아있는 변수. 특정 상황에서는 변경이 가능하며, 이 때문에 이런 종류의 변수를 다루는 것은 unsafe하여 메모리 관련 에러를 낼 수 있다.
- let: 일반 변수 선언. 하지만 값을 변경하는 행위에 제한이 있어 Trait에 따라 정의된 메서드로만 바꾸는 것이 가능한 경우가 일반적이다.
- let mut: 완전히 자유로운 변경이 가능한 변수 선언. 이런 변수에 RAM 메모리 관련 기능을 넣는 것은 unsafe한 경우가 있다.
mut 접두어는 변수를 다루는 데에 높은 자유도를 보장하지만, Rust의 개발 배경이기도 한 C언어 등의 기존 시스템 프로그래밍용 언어의 환장할 수준의 메모리 관리 난이도를 Rust에도 가져오는 모순적인 문제로 인해 가급적 덜 쓰는 것을 권장한다. 특히 RAM 영역을 다루는 Trait가 있다면 mut를 최대한 안 쓰려고 하는 것이 매우 중요하다.
사칙연산과 나머지는 C와 동일하게 +, -, *, /, %이다. 또 연산 후 이동인 +=, -=, *=, /=, %=도 있으나, C언어의 1 증감 연산자인 ++, --는 없다. 소괄호 (, ) 안에 연산식을 넣어서 복잡한 연산의 우선 순위를 끌어올릴 수 있다.
비교 연산자는 C언어처럼 <. >, ==, !=, <=, >=이 있다. 단, IEEE 754 부동소수점 타입의 변수는 직접 좌변과 우변에 변수를 두지 말고 두 변수의 차를 f32::EPSILON 또는 f64::EPSILON과 비교하는 식으로 하여 정밀도에 따른 오차로 발생하는 부정확한 비교 문제를 예방해야 한다.
데이터 자료형
또 let var1: u32 = 32;let var2 = 32.5_f32; 같이 변수의 데이터 타입을 지정하여 변수명 뒤에 콜론과 함께 붙이거나 초기화 수치 뒤에 언더바와 함께 붙인다. 지정 가능한 타입은 다음과 같다.
- u8 : 부호 없는 8비트 정수
- u16 : 부호 없는 16비트 정수
- u32 : 부호 없는 32비트 정수
- u64 : 부호 없는 64비트 정수
- i8 : 부호 있는 8비트 정수
- i16 : 부호 있는 16비트 정수
- i32 : 부호 있는 32비트 정수
- i64 : 부호 있는 64비트 정수
- f32 : 32비트 IEEE 754 단정밀도 부동소수점 실수
- f64 : 64비트 IEEE 754 배정밀도 부동소수점 실수
- usize : CPU가 한 사이클에 처리 가능한 최대 비트 크기의 부호 없는 정수
- isize : CPU가 한 사이클에 처리 가능한 최대 비트 크기의 부호 있는 정수
그 외에 숫자 앞에 0b로 2진법, 0o로 8진법, 0x로 16진법 수치를 나타내며, println! 내장 함수로 이런 포맷으로 출력하려고 한다면 {} 대신 각각 {:b}, {:o}. {:x} 구문을 쓰면 된다.
변수 타입 캐스팅은 변수 뒤에 as (변환하려는 타입)를 붙여서 서로 다른 타입의 변수를 비교할 수 있다.
문자열의 처리
Rust의 문자열은 std::String으로 구현되며, 내부에 있는 각종 메서드로 문자열을 처리할 수 있다. 주요 메서드는 다음과 같다.
- as_mut_str(): 변경 가능한 String 객체로 바꿔 그 참조를 반환한다. 일반적인 제한된 변경 변수는 as_str()을 대신 쓴다.
- push(character), pop(): 스택 방식의 데이터 밀어넣기/빼기
- insert(index, character): 문자열의 index 위치에 한 개의 글자로 이루어진 character를 삽입한다. 문자열을 끼워넣는 것은 insert_str(index, str) 메서드다.
다음은 String의 참조가 아닌 원본을 반환하는 메서드다.
- get(range): 문자열의 일부를 자르고 결과 반환
- trim(): 공백 제거한 결과 반환
- lines(): 문자열 내 줄바꿈 문자에 따라 문자열을 분할한다. 분할한 문자열은 next() 메서드로 단방향 순회할 수 있다.
- contain(pattern): 문자열 pattern이 내부에 존재하는지 문자열 매칭을 하여 boolean 값을 반환한다. 여기서 pattern은 단일 문자열일 수도 있고, 문자열이 담긴 배열일 수도 있다.
- matches(pattern): 문자열 패턴 매칭. 여기서 pattern은 단일 문자열일 수도 있고, 문자열이 담긴 배열일 수도 있다. pattern을 주어진 문자열 내에서 찾아 나온 횟수만큼의 pattern 내 문자열이 담긴 배열을 반환한다.
- split(pattern): pattern에 명시된 문자(또는 문자열)로 주어진 문자열을 쪼갠 배열을 반환한다. 여기서 pattern은 단일 문자열일 수도 있고, 문자열이 담긴 배열일 수도 있다.
- to_uppercase(), to_lowercase(): 영문 문자열의 대문자화, 소문자화
그 외
- len(): 문자열의 길이(usize) 반환. 참조의 메서드 버전과 원본에 대한 메서드 버전이 모두 있다.
Trait로 +가 정의되어 있어 concatenation이 된다.
리스트 자료형
Rust의 기본 리스트는 배열과 벡터 등이 있다.
- 배열
배열은 let array1: [u8; 4] = [1,3,2,5]; 처럼 변수 타입을 [(타입);(길이)]로 맞춰주면 정의된다. 원소에 접근할 때는 array1[0] 같은 식으로 C언어처럼 쓴다. 다중 배열은 [[u8; 4]; 5] 처럼 nested된 타입으로 선언한다.
- 슬라이스
슬라이스는 컴파일할 때 길이가 정해지지 않은 것을 제외하면 배열과 비슷하며, 배열의 참조 역시 슬라이스라고 불려 혼동되는 측면이 있다(배열의 참조는 for로 iteration을 할 때 쓰이는 경우가 많아 부정길이 리스트와 다르다).
- 벡터
Vec<A>는 A 타입의 벡터 자료형을 생성하며, 생성한 변수에서 각종 편리한 메서드 함수를 부를 수 있다. 다중 차원 벡터는 Vec<Vec<...>> 같은 식으로 생성한다. let vec1: Vec<u8> = Vec::with_capacity(5); 같이 with_capacity 함수를 써서 초기 크기를 정의하고 사용하는 것이 보통이다.
- 튜플
let tup1 : (A,B,...) = (1, 2, ...); 같은 식으로 타입을 선언하는 순서쌍 자료형이다. 내부 성분에 접근할 때에는 tup1.0, tup1.1, ... 등으로 접근한다.
Expression, Statement의 차이
Rust는 줄 단위의 구문이 끝에 세미콜론(;)으로 끝나는 구문과 그렇지 않은 구문으로 나뉜다. 전자가 Statement, 후자가 Expression인데, 둘의 차이는 값을 중괄호 {, }로 나타내는 스코프 내에서 다음 줄로 넘어갈 때 연산 결과를 버리느냐(Statement), 아니면 버리지 않고 스코프 바깥으로의 반환 등의 목적으로 남기느냐(Expression)의 차이다.
다음 예제를 보면 중괄호 바깥으로 expression은 값을 넘긴다.
let variable_outer:i32 = {
let variable_inner:i32 = 23; // statement인 이 줄은 23이라는 값을 varialbe_inner에 대입한 후 더 쓰지 않고 버린다.
variable_inner + 3 // expression인 이 줄은 값이 유지된 채로 variable_inner + 3이 스코프를 나가서 variable_outer에 들어간다.
}; // variable_outer가 있는 이 바깥 범위에서 variable_outer를 정의한 행위는 statement로, 26의 값을 다른 데 붙이지 않고 버린다.그러나 이런 혼란스러운 예제도 가능하다.
fn main() {
// The type of counter is i32, because it is not
// suffixed with a different literal type, and no
// use below causes a different type to be inferred.
let mut counter: i32 = 0;
// The type of result is the type of the loop
// expression, which by definition is the type of
// the expressions passed to `break` within the
// loop. There is only one `break`, which is passed
// counter * 2, of type i32.
let result: i32 = loop {
counter += 1;
if counter == 10 {
// The type of this expression is !.
// Since a semicolon follows, the value
// is discarded, but the expression has no value.
break counter * 2;
}
};
println!("The result is {}", result);
}이건 loop와 break 구문의 특수성 때문이다. loop는 break의 expression 부분이 반환하는 모든 종류의 데이터를 반환값으로 줄 수 있다. break는 뒤에 레이블이 있으면 그쪽으로 제어 흐름을 이동시키며, 값을 적어넣으면 그 값을 expression으로 스코프 바깥으로 넘기는 또 다른 특징이 있다. 그래서 break counter * 2는 expression으로 값을 유지하면서 중괄호 바깥의 loop에 값을 넘기며, break counter * 2;는 statement로 loop 뒤에 붙은 중괄호 안에서 break 이후에 다른 변수가 값을 쓰게 하지 않고 버린다.
조건문 및 반복문
- for
for 문은 C++11 이후의 C++의 범위 기반 반복문과 유사하며, C++98까지의 for문과 다르다.
가령 list라는 배열을 elem이라는 변수로 순회하고 싶다면 for elem in list{ ... }처럼 쓰는데, 이때 주의할 것은 list가 for 반복문을 돈 다음에 수명이 끝나 사라진다는 것이다. 즉 저 for 문을 사용한 이후에는 list를 사용할 수 없다. 따라서 for 문 이후에도 list를 사용하려면 for elem in &list{ ... }처럼 & 기호로 참조 처리를 해야한다. 또 Immutable Variable에 집착하는 Rust답게 list는 for 문 내에서 기본적으로 변경이 안 되기에 elem으로 list에 변경을 가하려면 for elem in &mut list{ ... }처럼 &mut 접두어를 list 앞에 붙여야 한다.
만일 단순한 n회 반복이 목적이라면 for _ in 0..n처럼 익명 변수를 쓰자. 여기서 n에는 가급적 상수를 쓰고 변수나 함수의 결과값을 넣지 않아야 성능 하락을 막을 수 있다.
- continue: 이 문구를 만나는 시점에서 continue가 있는 반복문의 현재 순회를 건너뛰고 다음 순회를 시작한다.
- break: 이 문구를 만나는 시점에서 현재 반복문을 중단한다.
- break (레이블 이름) : 레이블의 이름을 뒤에 붙일 경우
'(레이블 이름):접두어가 붙은 loop를 중단하며, 그 안에 있는 중첩된 반복문(Nested loop)도 같이 중단된다. goto 기능의 대용으로 쓴다.
- break (레이블 이름) : 레이블의 이름을 뒤에 붙일 경우
- while: 특정 조건이 된다면 계속 순회한다. 단 무한 루프를 의도하는 경우 loop가 낫다.
- loop: break를 마주할 때까지 무한반복. 이건 프로그래머들이 의도한 무한루프(예: Main 함수 내에서 중요 변수들을 초기화한 다음 종료할 때까지 하는 무한 루프)를 의도하지 않은 버그성 무한 루프와 코드 단계에서 구분하기 위해 만들었다. 버그성 무한 루프를 빨리 찾아 디버그 난이도를 낮추는 목적으로 사용이 권장된다.
- if, else if, else: C언어에도 있는 조건문. 활용할 상황이 C언어와 똑같으나, 해당 접두어 뒤에 소괄호를 붙여 그 안에 조건을 적는 C언어와 달리 소괄호를 넣지 않고 바로 조건을 적은 다음 중괄호 내 작업 구문이 나타난다.
if a == 42 {
} else if a > 42 {
} else {
}Rust의 함수 구조와 메모리 관련 기능
기존 변수의 참조(메모리 주소만 가져옴, C언어의 포인터에 대응)는 &을 앞에 붙이는 것으로 가능하며, 만일 어느 참조 변수의 원본을 찾고싶다면 참조 변수 이름 앞에 *을 띄어쓰기 없이 붙이면 된다(곱셈 연산자와 헷갈릴 수 있으므로 소괄호로 묶는 것을 추천한다)
Rust의 함수 작성 및 호출 예제를 보겠다.
fn functionname<'lifetime1>(param1: i32, param2: &'lifetime1 i32) -> i32 {
let var_inner: &'lifetime1 i32 = param2;
for _ in 0..*var_inner {
println!("Test Output with param3 {}", var_inner);
}
println!("Final param3 {}", var_inner);
// result
param1 + *param2
}
fn main() {
let var1:i32 = 6;
let result = functionname(32, &var1);
println!("{}", result);
}fn 옆에 함수 이름을 적고 그 다음 수명 지정 및 인자 지정을 한다. 인자들은 각각 변수명 : 타입 식으로 적으며 쉼표(,)로 구분한다. 그리고 functionname이 지금 -> 문자와 함께 i32를 반환한다는 정보를 주었으므로, expression으로 param1와 param2의 원본의 값을 더한 것을 반환한다.
functionname 바로 옆에 붙은 <'lifetime1>은 참조 변수들을 위한 수명 선언으로, param2에 참조 변수가 들어오므로 그 수명을 var1이란 이름으로 지정하겠다는 뜻이다. 그리고 그 내부의 로컬한 참조 변수 var_inner에도 lifetime1으로 수명이 명시되었기에, var_inner나 param2 둘은 functionname 안에서 같은 수명 범위를 가지게 된다. 즉 param2나 var_inner 중 하나가 인자로 넘긴 다른 함수의 호출에 쓰이며 거기서 그 수명을 다하도록 코드가 짜인 경우 같은 수명으로 묶인 다른 변수 때문에 컴파일 에러가 발생하게 된다. param2와 var_inner 내 데이터 사이에 의존성이 발생하면서 RAM 영역을 다루는 코드가 있다면 수명을 명시하여 param2이 소멸된 후 var_inner에 접근하는 등 허상 포인터(Dangling Pointer)가 발생하는 구조를 코드를 작성할 때부터 차단한다는 것이 Rust의 변수 수명 명시(Lifetime Annotation)가 존재하는 이유다.
번외: Closure
Closure는 일회용 함수로 람다 표현식 역할을 한다. 다음과 같이 쓴다.
let closure1 = | param1:i32 | -> i32 { param1 + 42 };
println!("{}", closure1(22)); // 64를 출력match
match는 C언어의 switch와 유사한 역할을 할 수 있지만, 직접 개별 케이스에 함수를 매칭하여 동작하게 할 수 있다.
item이라는 u32 타입 정수에 대해 다음 조건 매칭을 하는 match 사용 예시다.
match item {
0 => {...}, // item이 0인 경우 {...} 실행
10..=20 => {...}, // item이 10부터 20까지의 정수 중 하나인 경우 {...} 실행
40 | 80 => {...}, // item이 40 또는 80인 경우 {...} 실행
_ => {...}, // item이 그 외의 모든 범위의 데이터인 경우 {...} 실행
}여기서 {...}은 무조건 expression이다(; 붙이지 않음). 즉 여기에는 변수를 넣어서 반환하게 하거나 아니면 함수를 넣을 수 있다.
struct, enum
C언어처럼 Rust에서도 구조체를 선언할 수 있다.
struct STRUCTex1{
member1: u32,
member2: Vec<u8>,
}
fn main(){
let inst1 = STRUCTex1 {
member1: 9641,
member2: Vec::new(),
};
let var1:u32 = &inst1.member1;
println!("{:?}", STRUCTex1); // :? 리터럴은 출력시 구조체를 문자열로 변환하여 보여준다.
}그런데 C언어의 구조체 내 함수 포인터처럼 Rust의 구조체에 메서드를 추가할 수 있다. 그리고 메서드는 impl이라는 별도의 예약어를 사용해 메서드를 구조체 '바깥에서만 정의한다. 위 예제를 약간 변형하여 메서드를 보여준다.
struct STRUCTex1{
member1: u32,
member2: Vec<u8>,
}
impl STRUCTex1 {
// '&self', 또는 'self: &Self'은 구조체 자기 자신에 관한 데이터를 참조하겠다는 선언으로, 메서드를 사용할 때에는 넣어줄 파라미터 리스트에 들어가지 않는다.
fn method1(&self, param1: u32) -> u32 {
&self.member1 + 22
}
}
fn main(){
let inst1 = STRUCTex1 {
member1: 9641,
member2: vec![23, 26, 9832],
};
let var1:u32 = inst1.method1(42); // var1은 64가 된다
}enum은 열거형으로, 다음과 같이 쓴다. 공식 가이드의 열거형 예제를 보면 enum으로 선언한 열거형에도 메서드를 impl로 추가할 수 있다.
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
impl Message {
fn call(&self) {
// method body would be defined here
}
}
fn main(){
let m = Message::Write(String::from("hello"));
m.call();
}enum과 match와의 결합으로 모나드 구현
enum으로 정의한 열거형에 match를 사용하는 메서드를 정의하고, 그 메서드 내 match 내에 명시된 조건에 따르는 동작들이 enum으로 정의된 열거형 내 값을 반환하는 행위에서 벗어나지 않으면 모나드 개념에 따라 연쇄적으로 열거형의 해당 메서드를 호출하며 프로그래밍할 수 있다.
예외 처리
회복 불가 타입의 에러는 panic!("에러 메세지") 등으로 에러 메세지를 출력하고 프로그램을 중단할 수 있으며, 회복 가능한 에러(예: 파일 읽기 실패 등)의 경우 사전 정의된 Result 열거형(Ok와 Err 두개의 값을 가진다)를 활용해 match로 에러가 날 법한 결과물을 조사(예: File::open(...)을 시도한 결과물을 match)할 수 있다. 덤으로 Result 열거형으로 에러가 걸렸을 때 호출한 함수에서 호출한 쪽으로 Result 열거형의 값을 반환해 오류를 Propagate할 수 있다.
unwrap() 메서드를 사용하면 Result의 Err를 감지하여 자동으로 panic!을 호출하게 할 수 있다. expect(error_message)로 실패 가능성이 있는 기능 호출에 붙여서 디버깅 용도로 쓸 수 있으며, Rust는 컴파일러 단계에서 이걸 놓친 부분에 Warning을 통해 프로그래머가 디버깅을 하기 쉽도록 유도한다.
Generic
한편 C++의 템플릿처럼 데이터 타입에 상관없이 사용 가능한 함수를 정의할 수 있으며, 이를 제네릭 함수라고 한다. 그러나 C++언어가 함수 외부에 template <typename T> 같은 식으로 제네릭을 정의한다면 Rust는 함수마다 제네릭 타입을 붙인다.
그리고 Rust의 제네릭 함수는 함수 내부에 사용하는 연산들에 따라 선언한 Trait를 제네릭 사용 조건에 넣어야 한다. 다음 예시는 '같은 타입을 연산 결과로 반환하는 덧셈 Trait'가 T에 구현되었음을 제네릭 조건에 넣는 제네릭 함수다.
fn func1 <T: std::ops::Add<Output = T> > (i: T, j:T) -> T {i + j}Trait와 오버로딩
모듈을 사용한 코드 분할
패키지에서 Crate 가져오기, 네임스페이스
주요 Crate
- std : 기본적인 기능 라이브러리. 파일 작업 담당 fs, 화면이나 파일 등 입출력 전반을 담당하는 io 등
- num : 실수 외에 복소수 연산 같은 것도 지원한다.
- regex: 정규 표현식 라이브러리
- time: 시스템 시간 라이브러리
- rand: 임의의 숫자를 뽑는 Random Value 관련 기능
그 외
#[allow(...)]문법으로 특정 파일 컴파일 시의 경고 완화가 가능하다. unused_variables(사용하지 않고 선언만 된 변수가 있는 경우), dead_code(구현 안 된 부분) 등을 지정 가능하다. 반대로 경고 필수를 지정하려면#[warn(...)]을 쓴다.- 전역 변수를 바꾸는 것은 Rust가 보통은 권장하지 않으며, unsafe {...} 안에서 변수 수정 작업을 하게 한다.