Chapter 3 Common Programming Concepts
변수 선언
기본적으로 rust 의 변수는 immutable 합니다. 즉, 변하지 않는 값이 되죠.
(c 나 여러 언어에서 const 키워드를 사용하는 것과 같습니다. 기본 변수 타입이 const 인 셈이죠.)
이걸 통해 안전성과 쉬운 병렬성을 달성할수가 있게됩니다.(왜 그런지는 배우면서 차차 알수 있겠죠. 저도 아직 모름…)
물론, 변하는 값으로 설정하는 방법도 있습니다.
이제부터 왜 그렇게 설계했는지를 살펴봅시다.
먼저 변수를 선언하고나면, 그 이름을 가진 변수의 값을 바꿀수 없습니다.
한번 예시를 통해 살펴 볼게요.
fn main() {
let x = 5;
println!("The value of x is: {}", x);
x = 6;
println!("The value of x is: {}", x);
}
cargo check (또는 cargo run)을 해보면, 에러메세지가 나올겁니다.
앞으로는 물론, 아마도 평생(?!) 러스트 코드를 짜다보면 에러메세지를 만날텐데요,
실망하지마세요. 이건 아직 우리의 코드가 원하는 동작을 하기 위해 준비가 필요하다는 의미에 불과합니다.
보다 안전한 실행을 위해 꼼꼼히 체크하는 것입니다.
에러메세지는 이렇게 나올텐데요,
cannot assign twice to immutable variable x
우리가 x 변수에 다른 값을 넣으려고 해서 그렇습니다.
우리가 불변값으로 지정했던 값을 바꾸려고 할때 컴파일-타임 에러는 중요합니다.
바로 이런 상황이 버그로 이어질수 있기 때문이죠.
만약 우리 코드 중 일부가, 값이 절대 변하지 않을거야! 라는 가정 하에 실행되는데
다른 코드가 그 값을 바꾸려고 한다면, 원래 예상했던 동작이 일어나지 않겠죠.
이런 종류의 버그는 발생하고 나면 추적하기 어려울수도 있는게,
값을 바꾸는 일이 정말 가끔씩만 일어나기 때문입니다.
우리가 이 값은 절대 바뀌지 않을거야! 라고 한다면, 진짜 바뀌지 않습니다.
러스트에서는 컴파일러가 이런 일들을 보장해줍니다.
이게 뭘 의미하냐면,
우리가 코드를 읽고, 쓸때,
값이 언제 변할지를 신경쓰지 않아도 된다는 의미입니다.
따라서 코드를 추론하기가 더 쉬워지는 것이죠.
하지만 변하는 값도 아주 유용하죠.
(사실 실제 하드웨어에서는 메모리가 수시로 바뀌니까요)
변수가 불변하는게 기본이지만, mut 키워드를 쓰면 변하게 만들수도 있습니다.
단지 변하게 하는것 외에도, mut 키워드는 마치 책갈피 처럼,
내가 가리키는 지금 이 부분이 변할거야! 라는 의도를,
코드를 읽는 독자에게 전달해 줄수도 있습니다.
코드를 이렇게 바꿔볼까요?
mutable.rs
fn main() {
let mut x = 5;
println!("The value of x is: {}", x);
x = 6;
println!("The value of x is: {}", x);
}
결과는 이렇게 나오겠죠.
$ cargo run
Compiling variables v0.1.0 (file:///projects/variables)
Finished dev [unoptimized + debuginfo] target(s) in 0.30s
Running `target/debug/variables`
The value of x is: 5
The value of x is: 6
mut 키워드를 쓰면 x가 들고 있던 값을 5 에서 6 으로 변하게 할수 있도록 해줍니다.
몇몇 경우에서 우리는 굉장히 편리하다는 이유로,
불변하는 값만 쓰기보다는,
변하는 변수를 쓰는 유혹을 많이 받으실텐데요. (근데 변수 원래 뜻이 변할수 있는 수 아니야?)
단지 버그를 방지하기 위해서뿐만 아니라,
고려해야할 몇가지 트레이드 오프(일장일단, 절충안)가 있습니다.
예를 들면 큰 데이터 구조를 사용하는 경우에는
복사하고 다시 새로 할당된 인스턴스를 리턴 하기 보다,
그 자리에서 바로 값을 바꿔버리는게 빠를수도 있습니다.
작은 데이터 구조에서는,
새로운 인스턴스를 만들고, 함수형 프로그래밍 스타일로 작성하는것이 더 생각를 이어가기에 쉬울수 있기때문에,
조금 성능을 희생하더라도, 명확성을 얻을수 있다는 장점을 가져갈수 있습니다.
변수와 상수의 차이
변수의 값을 바꿀수 없다는 개념은, “상수"에 대해서 다시 생각해보게 합니다.
불변하는 변수와 같이, 상수도 어떤 이름에 할당된 변하지않는 값이지만,
조금 다른 점들이 있습니다.
가령, mut 키워드를 상수에는 적용할수 없습니다.
그냥 상수는 항상 불변합니다.
상수는 const 키워드로 선언할수 있습니다.
그리고 반드시 타입을 지정해줘야합니다.
타입에 대한 얘기는 다음 절에 나오니 우선 넘어갑시다.
상수는 전역 변수든 어디 위치에서든 선언 될수 있습니다.
코드의 많은 부분에서 알아야하는 값일 경우 유용합니다.
마지막으로 다른 점은,
상수로 선언하는 방식 외에,
함수 호출의 결과나
런타임시에 계산해야만 알수있는 값은 상수가 될수 없다는 점입니다.
예를 볼까요.
MAX_POINTS 라는 이름의 상수가 100,000 이라는 값을 가지고 있습니다.
(러스트에서 상수 이름을 만들때는 전부 대문자를 사용하고 단어 사이에 언더스코어(_)를 사용합니다.)
(숫자에 대한 가독성을 높히기 위해 사용되기도 합니다.)
#![allow(unused)]
fn main() {
const MAX_POINTS: u32 = 100_000;
}
상수는 프로그램이 실행되는 동안, 선언된 스코프 내에서 유효하기때문에,
애플리케이션 영역에서, 프로그램의 여러 파트에서 알아야할 값들,
게임 플레이어가 모아야하는 최대 점수라던가, 빛의 속도와 같은 경우에 유용합니다.
의미있는 이름으로 하드코딩된 값은
다른 메인테이너(코드 유지 보수하는 사람)에게 코드를 보여줄때 요긴하게 사용될수 있습니다.
또한 나중에 하드코딩된 값을 바꿔야 할 경우에, 한 부분만 바꾸면 되는 이점을 제공합니다.
쉐도잉
원래 선언했던 변수 이름으로 다시 설정 가능합니다.
이럴때 러스트에서는 shadowed(가려졌다) 되었다고 말합니다.
예시를 보면서 설명해볼까요.
fn main() {
let x = 5;
let x = x + 1;
let x = x * 2;
println!("The value of x is: {}", x);
}
처음에 x 는 5 라는 값에 묶이죠.
그리고 다시 let 키워드를 통해서 x에 1을 더한 6 이라는 값에 묶입니다.
마지막으로 세번째 선언을 통해 그 값을 2배 하면서 다시 x에는 12 라는 값으로 묶입니다.
쉐도잉(shadowing) 은 mut키워드로 선언된 변수와는 다른데요,
let 키워드를 사용하지않으면 재할당되지 않기 때문입니다.
let 키워드를 사용한 할당 뒤에는 그 값은 불변합니다.
또 다른 mut와 shadowing 의 차이점이라면,
let키워드로 너무나 효과적으로 새로운 변수를 만들수 있기때문에,
같은 이름을 사용한, 다른 타입의 변수를 만들수가 있습니다.
예를 들어, 유저에게 공백문자를 넣어 텍스트 사이에 몇 개의 공백을 원하는지 보여달라는 프로그램이 있을때, 그 입력을 숫자로 저장하고 싶다고 생각해봅시다.
let spaces = " ";
let spaces = spaces.len();
첫번째 변수는 문자열 타입이고, 두번째 변수는 숫자 타입입니다.
쉐도잉을 사용하면, space_str, space_num 과 같이
다른 이름을 사용하지 않아도 됩니다.
하지만 mut 키워드를 사용해서 위와 같은 작업을 실행하면 에러가 납니다.
let mut spaces = " ";
spaces = spaces.len();
$ cargo run
Compiling variables v0.1.0 (file:///projects/variables)
error[E0308]: mismatched types
--> src/main.rs:3:14
|
3 | spaces = spaces.len();
| ^^^^^^^^^^^^ expected `&str`, found `usize`
error: aborting due to previous error
For more information about this error, try `rustc --explain E0308`.
error: could not compile `variables`
To learn more, run the command again with --verbose.
즉, mut키워드를 사용해도 타입은 바꿀수 없다는 겁니다.
자, 이제는 타입에 대한 내용을 다뤄보겠습니다.
데이터 타입
러스트에서 모든 변수는 특정한 타입을 가지고 있습니다.
어떤 종류의 데이터가 지정되었는지 알려줌으로써,
어떻게 그 데이터를 처리할지를 알려주는 것이죠.
데이터 타입를 두가지로 나눠서 살펴보겠습니다:
스칼라와 컴파운드
러스트는 정적 타입 언어 라는 사실을 명심하세요.
컴파일 타임에 모든 변수의 타입을 알고 있어야한다는 사실입니다.
컴파일러는 보통 변수의 값과 어떻게 그것을 사용할것인지에 따라 어떤 타입인지를 유추할수 있습니다.
많은 타입이 유추 가능할 경우에,
예를 들면 parse를 사용하여 문자열 타입에서 숫자형 타입으로 바꾸는 경우,
타입 어노테이션(힌트,설명)을 추가해야합니다:
let guess: u32 = "42".parse().expect("Not a number!");
2021-08-04T14:28:27+09:00 2021-08-05T13:11:53+09:00
만약 타입 어노테이션을 추가하지 않으면 에러가 납니다.
어떤 타입을 사용할것인지 우리에게 물어보는 것이죠.
$ cargo build
Compiling no_type_annotations v0.1.0 (file:///projects/no_type_annotations)
error[E0282]: type annotations needed
--> src/main.rs:2:9
|
2 | let guess = "42".parse().expect("Not a number!");
| ^^^^^ consider giving `guess` a type
error: aborting due to previous error
For more information about this error, try `rustc --explain E0282`.
error: could not compile `no_type_annotations`
To learn more, run the command again with --verbose.
다른 데이터 타입들에 대한 어노테이션을 볼까요.
스칼라 타입
스칼라 타입은 단일 값을 나타냅니다.
러스트는 네 가지 기초 스칼라 타입이 있습니다.
integers, floating-point numbers, booleans, characters.
아마 다른 프로그래밍 언어에서 많이 접했을 개념입니다.
하나씩 살펴보죠.
integer 타입
integer 는 소수점이 없는 숫자 입니다.
i32 는 signed integer, u32 는 unsigned integer 입니다.
32비트의 공간을 차지하고, 부호가 있느냐 없느냐의 차이입니다.
Table 3-1: Integer Types in Rust
| Length | Signed | Unsigned |
|---|---|---|
| 8-bit | i8 |
u8 |
| 16-bit | i16 |
u16 |
| 32-bit | i32 |
u32 |
| 64-bit | i64 |
u64 |
| 128-bit | i128 |
u128 |
| arch | isize |
usize |
명시적인 크기를 가지고, 부호가 있거나 없거나 하는 식의 변형이 있습니다.
부호가 있는 숫자는 two’s complement(2의 보수) 표기법으로 저장됩니다.
부호가 있는 경우, \( -(2^{n-1}) \) 부터 \( 2^{n-1}-1 \) 이하(포함) 의 값을 저장할수 있습니다.
i8의 경우는 -128~127 까지의 값을 저장하죠.
부호가 없는 경우, 0부터 \( 2^{n}-1 \) 의 값을 저장합니다.
u8이면 0 부터 255 까지요.
isize ,usize 같은 ~size 의 타입은 사용하는 아키텍처에 따라 달라집니다.
가령, 64비트 아키텍처라면 i64,u64가 되는 것이죠.
정수 표현하는 방식은 아래 표에 나온 것과 같습니다.
바이트 정수를 제외하고 모든 표기에는 접미사(suffix)를 허용합니다.
57u8 이런 식으로요.
그리고 언더스코어(_) 는 구분자로 사용가능합니다.
1_000 이렇게 말이죠.
Table 3-2: Integer Literals in Rust
| Number literals | Example |
|---|---|
| Decimal | 98_222 |
| Hex | 0xff |
| Octal | 0o77 |
| Binary | 0b1111_0000 |
| Byte (u8 only) | b’A' |
어떤 타입을 사용할지 모르겠다고요?
그렇다면 디폴트 타입인 i32 가 일반적으로 좋은 선택일겁니다.
(제일 빠릅니다(심지어 64비트 시스템에서도))
일종의 컬렉션을 인덱싱할때 주로 isize 나 usize를 사용합니다.
(나중에 이 부분은 다시 살펴 보도록 합시다.)
Integer Overflow
0에서 255까지 저장할수 있는
u8타입의 변수를 사용한다고 해볼까요.
만약, 이 범위 밖의 값, 예를 들면 256을 저장하려고 한다면,
integer overflow 가 발생합니다.
러스트에는 이것과 관련된 흥미로운 규칙이 있습니다.
디버그 모드에서 컴파일 할때, 러스트는 integer overflow 를 체크합니다.
만약 런타임에 일어나게 된다면 프로그램이 패닉 에 빠지게 될수 있으니까 말이죠.
러스트는 프로그램이 에러로 종료되면 패닉에 빠졌다는 표현을 사용하는데,
챕터 9에서 이 부분을 좀더 다뤄보도록 하겠습니다.
--release플래그를 붙여서 릴리즈 모드로 컴파일하게 되면,
integer overflow 를 체크하지 않습니다.
대신, 오버플로우가 일어나면, two’s complement wrapping 을 실행합니다.
간단히 말해서, 변수가 담을수 있는 최댓값보다 큰 값은, 최솟값으로 “감싸집니다(wrapping)”.
u8의 경우, 256은 0이 디고, 257은 1이 되는 식입니다. (0 1 … 255 0 1 …)
프로그램은 패닉에 빠지지않지만, 원래 예상했던 동작을 하지 않을수도 있습니다.
랩핑에 의존하는것은 에러로 간주됩니다.명시적으로 오버플로우 가능성을 처리하기 위해,
표준 라이브러리가 기본 숫자 타입에 제공하는 메소드 모음을 사용할수 있습니다.
- ◼
wrapping_add처럼, 모든 모드에서 랩핑하는wrapping_*- ◼ 오버플로우가 있으면
None값을 반환하는checked_*- ◼ 값과 오버플로우 여부를 알려주는 boolean 을 반환하는
overflowing_*- ◼ 값의 최소와 최대값으로 고정되는 (포화)
saturating_*
floating-point 타입
러스트는 부동소수점 숫자를 위한 두가지 기본 타입이 있습니다.
f32와 f64 인데, 각각 32비트와 64비트의 사이즈를 가지고 있습니다.
현대 cpu의 대부분이 64비트를 채용하고 있고, 속도는 비슷한데 더 정밀하기 때문입니다.
예제를 보면서 설명해보죠.
fn main() {
let x = 2.0; // f64
let y: f32 = 3.0; // f32
}
부동소수점 숫자는 IEEE-754 표준에 따라 표기 됩니다.
f32 타입은 단일-정밀도 이고, f64 더블-정밀도 입니다.
(이 부분 용어 설명은 나중에 포스팅하기로 할게요.)
Numeric Operations
러스트는 간단한 숫자 연산을 지원합니다.
덧셈, 뺄셈, 곱셈, 나눗셈, 모듈러(나머지) 연산 등이죠.
fn main() {
// addition
let sum = 5 + 10;
// subtraction
let difference = 95.5 - 4.3;
// multiplication
let product = 4 * 30;
// division
let quotient = 56.7 / 32.2;
// remainder
let remainder = 43 % 5;
}
위의 예시에서 각 연산의 결과값은 변수에 바인딩(묶임)됩니다.
Appendix B 에서 모든 연산의 종류를 확인할수 있습니다.
Boolean type
대부분의 프로그래밍 언어에서 다루는, true, false 값을 가지는 불리언 타입입니다.
Boolean 타입은 1바이트의 사이즈를 가집니다.
fn main() {
let t = true;
let f: bool = false; // with explicit type annotation
}
주로 if문에서 사용하죠.
이후에 control flow 챕터에서 다뤄보도록 합시다.
character type
지금까지는 숫자에 대해서만 다뤘지만, 러스트는 문자도 지원합니다.
char 타입은 문자열 타입과는 다르게 '' 단따옴표를 사용하여 표기합니다.
fn main() {
let c = 'z';
let z = 'ℤ';
let heart_eyed_cat = '😻';
}
char타입은 4 바이트이며, 유니코드 값을 나타낼수 있습니다.
ASCII 말고도 수많은 표현이 가능하다는 얘기입니다.
강조 문자, 중국어, 일본어, 한국어, 이모지, 공백문자 까지 모두 가능합니다.
유니코드는 U+0000 부터 U+D7FF, 그리고 U+E000 부터 U+10FFFF 까지(포함) 가능합니다.
일반적으로 ‘character’라고 하면 유니코드랑은 좀 개념이 맞지않아서 직관적으로 다소 이해되지 않을수 있습니다.
이러한 부분은 storing UTF-8 Encoded text with strings에서 좀더 살펴보겠습니다.
Compound Types
컴파운드 타입은 여러개의 값을 하나의 타입으로 묶을수 있습니다.
러스트는 두개의 기본 컴파운드 타입이 있습니다:
tuples 와 arrays 입니다.
Tuple type
튜플은 여러개의 다양한 타입을 가진 값들을 하나로 묶는 일반적인 방법입니다.
튜플은 고정 길이를 가집니다:
즉, 한번 선언되면 늘어나거나 줄어들지 않습니다.
괄호안에 쉼표로 구분하는 리스트를 만들어서 튜플을 생성할수 있습니다.
각 위치는 타입을 가질수 있으며, 타입이 꼭 전부 같을 필요는 없습니다.
아래 예제에서는 타입 어노테이션(선택 가능: 해도 되고 안해도 됨)을 붙여봤습니다.
main.rs
fn main() {
let tup: (i32, f64, u8) = (500, 6.4, 1);
}
tup 이라는 변수는 전체 튜플을 잡아둡니다.
튜플은 전체가 하나의 요소로 취급되기 때문입니다.
튜플로부터 각각의 값을 가져오려면, 패턴 매칭을 사용하여 아래와 같이 튜플 값을 분해할수 있습니다.
fn main(){
let tup = (500, 6.4, 1);
let (x, y, z) = tup;
println!("The value of y is: {}", y);
}
이 프로그램은 먼저 튜플을 만들고 tup이라는 변수에 묶습니다.
let키워드와 함께 패턴을 사용해서 tup 변수를 받아서 3개의 분리된 값으로 바꿉니다.
이것을 destructuring(해체) 이라고 부릅니다.
하나의 튜플을 세 부분으로 나누기 때문이죠.
마지막으로 프로그램은 y의 값인 6.4를 프린트하게 됩니다.
패턴 매칭으로 해체하는 방식 외에도,
마침표(.)를 이용하여 바로 튜플 요소에 접근할수 있습니다.
튜플.{순서}
예제를 한번 봅시다.
fn main() {
let tup2: (i32, f64, u8) = (500, 6.4, 1);
let five_hundred = tup2.0;
let six_point_four = tup2.1;
let one = tup2.2;
}
먼저 tup2라는 변수를 만들고, 튜플의 인덱스(순서)를 고려하여 새로운 변수에 넣었습니다.
대부분의 프로그래밍 언어가 그렇듯, 인덱스는 0 부터 시작합니다.
Array 타입
또 다른 방식으로, array 를 이용하면 여러 값의 집합을 만들수 있습니다.
tuple 과는 다르게 array의 모든 요소는 같은 타입을 가져야 합니다.
다른 프로그래밍 언어들과는 다르게 러스트의 array는 고정된 길이만을 갖습니다.
대괄호[] 안에 쉼표로 구분하여 적습니다.
fn main() {
let a = [1, 2, 3, 4, 5];
}
어레이는 힙 보다는 스택에 데이터를 지정하고 싶을때 유용합니다.
(스택과 힙에 대해서는 챕터 4에서 좀더 알아보도록 합시다.)
또는 항상 고정된 갯수의 요소를 가질것이라고 확신할수 있을때 사용합니다.
어레이는 벡터 타입 만큼 유연하지 않습니다.
벡터는 표준 라이브러리를 통해 제공되는 집합(collection) 타입이고, 사이즈가 늘어나거나 줄어드는 것이 가능합니다!
만약 어레이를 쓸지 벡터를 쓸지 확실하지 않다면 벡터를 사용하게 될겁니다.
(챕터 8 에서 벡터에 대해 좀더 알아보도록 합시다.)
프로그램에서 벡터 대신 어레이를 쓰게될 상황에 대한 예로는,
월별 이름을 알아야 할때가 있습니다.
새로운 달이 추가되거나 사라질 일이 왠만해서는 없기때문에,
어레이를 사용할수 있습니다. 항상 12개의 요소를 가질 것이기 때문이죠.
let months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
아래 처럼 어레이의 타입을 적는 경우,
대괄호안에 요소의 타입을 적고, 세미콜론을 붙인 다음,
그 옆에 어레이의 사이즈를 적어주면 됩니다.
let a: [i32; 5] = [1, 2, 3, 4, 5];
여기에서 i32는 각 원소들의 타입이고 5는 어레이가 5개의 원소를 포함한다는 의미입니다.
동일한 원소로 어레이를 만들려면 타입 대신 해당 원소를 적어주면 됩니다.
let a = [3; 5];
이렇게 하면 3이 5개 들어있는 어레이가 생성됩니다.
어레이 원소 접근
어레이는 스택에서 하나의 메모리 덩어리로 지정됩니다.
어레이의 원소는 아래 처럼 인덱스로 접근할수 있습니다:
fn main() {
let a = [1, 2, 3, 4, 5];
let first = a[0]; // 1
let second = a[1]; // 2
}
참고: 어레이의 인덱스도 0부터 시작합니다.
잘못된 어레이 원소 접근
만약 맨 마지막 원소를 지나서 접근을 하려고 한다면 어떻게 될까요?
(챕터 2를 하고 온 다음에 다시 보기)
(사용자 입력을 받아서, 어레이의 해당 인덱스의 원소를 출력하는 프로그램)
thread 'main' panicked at 'index out of bounds: the len is 5 but the index is 10', src/main.rs:19:19
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
이러한 결과는 런타임 에러로, 잘못된 인덱스를 가리켰을때 나타납니다.
프로그램은 마지막 프린트문을 출력하지 못한채로 종료됩니다.
이런식으로 사용자가 어떤 값을 입력할지 모르는 경우, 컴파일러가 컴파일 전에 체크할수가 없기때문에 런타임 에러가 발생할수가 있습니다.
바로 이것이 현실에서 일어날수 있는
러스트의 안전 원칙 중 첫번째 예입니다.
많은 저레벨 언어들에서 이런 종류의 체크는 수행되지 않고,
잘못된 인덱스를 넣으면 잘못된 메모리 주소에 접근할수가 있습니다.
러스트에서는 이러한 종류의 에러를 미연에 방지하는 방법으로 프로그램을 바로 종료시키게 됩니다.
챕터 9에서 러스트의 에러 핸들링을 좀더 알아보기로 합시다.
Functions
러스트 코드에는 함수가 널리 퍼져있습니다(?).
그 중에서도 가장 중요한 함수를 이미 우리는 알고 있죠: 많은 언어에서 엔트리 포인트(시작점) 으로 사용되는 main 함수입니다.
새로운 함수를 선언하는 fn키워드도 한번 본적이 있습니다.
러스트 코드는 함수나 변수 이름을 만들때 관습적으로 snake case 를 사용합니다.
snake case 에서는 모든 문자가 소문자이고, 언더스코어(_)로 단어들을 구별합니다.
예시를 봅시다:
fn main() {
println!("Hello, world!");
another_function();
}
fn another_function() {
println!("Another function.");
}
러스트에서 함수를 선언할때는 fn 키워드로 시작하고,
함수 이름 뒤에 괄호를 붙입니다.
중괄호는 컴파일러에게 함수 바디(body)의 시작과 끝을 알려줍니다.
함수 이름을 쓰고 뒤에 괄호를 붙이면 우리가 정의한 어떤 함수든 호출할수 있습니다.
노트:
유의할점은, 소스코드에서는 main함수 뒤에 another_function을 정의하였지만, 앞에서 정의해도 상관없습니다.
어딘가에만 정의되어 있으면 됩니다.
함수를 실행해보면 다음과 같은 결과가 나옵니다:
$ cargo run
Compiling functions v0.1.0 (file:///projects/functions)
Finished dev [unoptimized + debuginfo] target(s) in 0.28s
Running `target/debug/functions`
Hello, world!
Another function.
main 함수 내에서 호출된 순서대로, Hello, world! 다음에 Another function. 이 차례로 호출되는것을 확인할 수 있습니다.
Function Parameters
함수의 시그니처(함수의 구성요소) 중 하나로서,
특별한 변수로 사용되는 “parameter” 를 가지도록 함수를 정의할 수도 있습니다.
함수가 parameter 를 가지고 있을때, 구체적인 값을 가지도록 할수도 있습니다.
기술적으로는, 구제척인 값을 “argument” 라고 하지만(함수에서 정의된 변수가 아닌 구체적인 값: example ➜ funcion1(2) ➜ 여기서 2를 말함),
일상 대화에서 우리가 parameter 와 argument를 사용할때는 딱히 구분없이 말하기도 합니다.
다음 예를 보면서 설명해볼까요.
fn main() {
another_function(5);
}
fn another_function(x: i32) {
println!("The Value of x is: {}",x);
}
실행해보면 아래와 같이 나올겁니다.
$ cargo run
Compiling functions v0.1.0 (file:///projects/functions)
Finished dev [unoptimized + debuginfo] target(s) in 1.21s
Running `target/debug/functions`
The value of x is: 5
another_function 함수를 선언할때, 파라미터는 x 하나이고 타입은 i32입니다.
5라는 값이 함수로 들어가면, println!이라는 매크로가 형식 문자열(format string) 내에서 중괄호가 있는 위치에 5를 넣습니다.
함수 시그니처(구성요소)에서, 파라미터의 타입은 반드시 지정해줘야 합니다.
이런 특징은 러스트 언어를 디자인할때 의도적으로 고려된 결정입니다:
함수 정의할때 미리 타입을 선언한다는 것은,
컴파일러가 어떤 의미로 코드를 짰는지 알기위해
해당 변수를 사용한 곳들을 찾아갈 필요가 거의 전혀 없다는 의미입니다.
함수가 여러개의 파라미터를 갖기를 원한다면,
쉼표로 파라미터를 구분하여 선언하면 됩니다. 이렇게요:
fn main() {
another_function(5, 6);
}
fn another_function(x: i32, y: i32) {
println!("The value of x is : {}", x);
println!("The value of y is : {}", y);
}
이 예제는 두개의 파라미터를 갖는 함수를 만들었습니다. 같은 i32타입을 가지고 있네요.
그리고선 두개 파라미터가 가지는 값을 바로 출력합니다.
당연한 얘기지만, 모든 파라미터가 같은 타입을 가져야만 할 이유는 1도~~(하나도)~~ 없습니다.
그냥 예제가 그럴뿐이죠.
예제를 실행하면 아래처럼 나올겁니다.
$ cargo run
Compiling functions v0.1.0 (file:///projects/functions)
Finished dev [unoptimized + debuginfo] target(s) in 0.31s
Running `target/debug/functions`
The value of x is: 5
The value of y is: 6
익히 예상할수 있는것처럼
차례로 5와 6을 넣었기 때문에,
출력되는 문자열도 똑같은 순서로 나오는 것을 확인할수 있습니다.
Function Bodies Contain Statements and Expressions
함수 본문은 선택적으로는 표현식으로 끝나는, 일련의 문장으로 구성됩니다.
지금까지는 종료 표현식이 없는 함수만 다루었지만, 표현식은 명령문의 일부로 보았습니다.
러스트는 표현식 기반 언어이기 때문에, 이런 내용은 꼭 이해해야할 중요한 차이점 입니다.
다른 언어에는 이러한 구분이 없기때문에, 명령문장과 표현식이 무엇이고,
그 차이가 함수 본문에 어떤 영향을 미치는지 한번 살펴보겠습니다.
우리는 사실 이미 명령식과 표현식을 사용했습니다.
명령식(statement) 은 값을 리턴하지않으면서 어떤 행위를 수행하는 지시문입니다.
표현식(Expressions) 은 결과값을 도출합니다.
예제를 통해 살펴봅시다.
변수를 만들고 let키워드로 값을 할당하는 이런 형태는 Statement(명령식) 입니다.
아래 예제 코드에서 let y = 6; 이 문장이 명령식입니다.
fn main() {
let y = 6;
}
↑ Listing 3-1: A main function declaration containing one statement
함수 정의 또한 Statement 입니다.
앞서 나온 전체 예제는 그 자체로 statement 입니다.
Statements 는 값을 리턴하지 않습니다.
따라서 let statement 를 다른 변수에 다시 할당할수 없습니다.
다음 예제를 실행하면 에러가 발생합니다.
fn main() {
let x = (let y = 6);
}
실제로 프로그램을 실행하면 다음과 같은 에러가 나올겁니다.
$ cargo run
Compiling functions v0.1.0 (file:///projects/functions)
error[E0658]: `let` expressions in this position are experimental
--> src/main.rs:2:14
|
2 | let x = (let y = 6);
| ^^^^^^^^^
|
= note: see issue #53667 <https://github.com/rust-lang/rust/issues/53667> for more information
= help: you can write `matches!(<expr>, <pattern>)` instead of `let <pattern> = <expr>`
error: expected expression, found statement (`let`)
--> src/main.rs:2:14
|
2 | let x = (let y = 6);
| ^^^^^^^^^
|
= note: variable declaration using `let` is a statement
warning: unnecessary parentheses around assigned value
--> src/main.rs:2:13
|
2 | let x = (let y = 6);
| ^^^^^^^^^^^ help: remove these parentheses
|
= note: `#[warn(unused_parens)]` on by default
error: aborting due to 2 previous errors; 1 warning emitted
For more information about this error, try `rustc --explain E0658`.
error: could not compile `functions`
To learn more, run the command again with --verbose.
let y = 6 statement 는 값을 반환하지 않다보니, x에 묶어놓을 값이 없습니다.
이런 특징이, 할당을 하면 할당된 값을 반환하는 다른 언어(C,Ruby 등)들과 다른 점입니다.
만약 x = y = 6 이런식으로 쓰면 x 와 y는 모두 6이라는 값을 가집니다.
러스트에서는 이런 경우가 없습니다.
Expressions 는 어떤 값을 도출해내고, 러스트에서 작성할 코드의 나머지 대부분을 차지합니다.
간단한 수학 연산을 생각해볼까요.
5+6이라는 표현식에서 결과는 11이 나옵니다.
표현식(expressions)은 선언식(statement) 의 일부가 될수 있습니다:
위 예제 3-1 의 let y=6에서 6은 6이라는 값으로 도출되는 표현식입니다.
함수를 호출하는 것도 표현식입니다.
매크로를 호출하는 것도 표현식입니다.
새로운 스코프를 만들때 사용하는 블록,{},도 표현식입니다.
예를 들면 이렇습니다.
fn main() {
let x = 5;
let y = {
let x = 3;
x + 1
};
println!("The value of y is : {}", y);
}
아래 표현식을 보세요:
{
let x = 3;
x + 1
}
이 블록은 4라는 결과로 나타납니다.
이 값은 y에 묶이게 되고(bound to) let문장의 일부분입니다.
여기서 주의할점은 x+1 에는 세미콜론; 이 마지막에 없습니다.
지금까지 본적없는 형태이죠.
expressions(표현식)은 마지막 세미콜론을 포함하지 않습니다.
마지막에 세미콜론을 찍으면, 그 문장은 statement(선언식)으로 바뀌게 되고, 값을 반환하지 않게 됩니다.
이것을 명심하시고, 다음부터 배울 함수 값반환과 표현식으로 넘어가보도록 합시다.
Functions with Return Values
함수는 자신을 부른 코드에 값을 반환할수 있습니다.
반환하는 값의 이름을 붙이진않지만, 타입은 지정할수 있습니다.
화살표(->) 뒤에 붙입니다.
러스트에서, 반환값은 함수 본문의 블록에서 가장 마지막 표현식의 값과 같습니다.
값을 지정해서 return 키워드로 먼저 끝내버릴수도 있지만, 대부분의 함수는 암시적으로 마지막 표현식을 반환합니다.
아래는 값을 반환하는 함수의 예입니다.
fn five() -> i32 {
5
}
fn main() {
let x = five();
println!("The value of x is : {}", x);
}
여기에 있는 five함수안에는 함수 호출도 없고, 매크로도, 심지어 let statement도 없습니다 — 그냥 숫자 5가 들어있을 뿐이죠.
러스트에서는 완벽하게 유효한 함수입니다.
함수의 반환 타입도 명시되어 있다는 것에 주목하세요. (-> 32)
위 코드를 실행하면 아래와 같이 나올겁니다.
$ cargo run
Compiling functions v0.1.0 (file:///projects/functions)
Finished dev [unoptimized + debuginfo] target(s) in 0.30s
Running `target/debug/functions`
The value of x is: 5
five함수의 5는 함수의 반환 값이고, 리턴 타입이 i32인 이유입니다.
좀더 자세히 살펴봅시다.
두가지 중요한 부분이 있습니다.
첫번째로, let x = five(); 이 줄에서 변수를 초기화하기 위해서 함수의 반환값을 이용하였습니다.
five함수가 5를 반환했기때문에, 아래와 같은 줄이 됩니다.
let x = 5;
두번째로, five함수는 파라미터가 없고, 반환 타입만 정의하였습니다.
함수 본문에는 외로이 5가 있을뿐이고, 세미콜론;도 없습니다.
선언식이 아닌 표현식이기 때문이며, 반환하고 싶은 값이기 때문입니다.
다른 예제를 살펴보죠.
fn main() {
let x = plus_one(5);
println!("The value of x is: {}", x);
}
fn plus_one(x: i32) -> i32 {
x + 1
}
위 코드를 실행하면 The value of x is: 6 이 나올겁니다.
하지만 x+1이 있는 이 줄에 세미콜론을 추가한다면, statement로 바뀌면서 에러가 나게 됩니다.
fn main() {
let x = plus_one(5);
println!("The value of x is: {}", x);
}
fn plus_one(x: i32) -> i32 {
x + 1;
}
이 코드를 컴파일 하면 아래처럼 에러가 나올 겁니다.
$ cargo run
Compiling functions v0.1.0 (file:///projects/functions)
error[E0308]: mismatched types
--> src/main.rs:7:24
|
7 | fn plus_one(x: i32) -> i32 {
| -------- ^^^ expected `i32`, found `()`
| |
| implicitly returns `()` as its body has no tail or `return` expression
8 | x + 1;
| - help: consider removing this semicolon
error: aborting due to previous error
For more information about this error, try `rustc --explain E0308`.
error: could not compile `functions`
To learn more, run the command again with --verbose.
주요 에러 메세지는 “타입 미스매치(타입이 잘못 매칭되었음)” 입니다.
plus_one 함수의 정의를 보면, i32타입을 반환한다고 되어있습니다.
하지만 statement선언식은 값으로 도출되지않고 비어있는 튜플()로 표현될 뿐입니다.
그러므로, 아무것도 반환되지 않으며, 함수 정의에 위배되므로, 에러가 발생하게 됩니다.
이 결과에서 러스트는 문제를 수정하는데 도움이 될수 있는 메세지를 제공합니다:
세미콜론을 제거하면 문제를 해결할수 있을거라고…
Comments
모든 프로그래머들은 자신의 코드를 이해하기 쉽게 만들기 위해 노력하지만, 때때로 추가적인 설명이 필요합니다.
이런 경우에 노트를 남기거나, 코멘트 를 남깁니다:
컴파일러는 소스코드의 코멘트를 무시하지만 코드를 읽는 사람들에게는 매우 유용할수 있습니다.
간단한 코멘트를 볼까요.
// hello, world
러스트에서는 관용적으로 두개의 슬래쉬// 로 코멘트를 시작하고 그 줄의 끝까지를 주석으로 간주합니다.
여러줄에 주석을 적으려면 이렇게 합니다:
// So we’re doing something complicated here, long enough that we need
// multiple lines of comments to do it! Whew! Hopefully, this comment will
// explain what’s going on.
각 줄에 두개의 술래쉬//를 적으면 됩니다.
주석은 코드를 포함한 줄에도 사용 가능합니다.
fn main() {
let lucky_number = 7; // I’m feeling lucky today
}
하지만 아래와 같이
설명하려는 코드 윗줄에 주석을 적는 것이 보통입니다.
fn main() {
// I’m feeling lucky today
let lucky_number = 7;
}
러스트에는 documentation comments 라는 다른 종류의 주석도 있는데,
이 내용은 챕터 14의 “Publishing a Crate to Crates.io” 섹션에서 더 다뤄보도록 하겠습니다.
Control Flow
어떤 조건하에서 실행 할지 말지 결정하는것과, 어떤ㄷ 조건하에서 어떤 코드를 반복 실행하는 것은,
대부분의 프로그래밍 언어에서 기본적인 빌딩 블록 입니다(로직,논리를 쌓아나가는 기본 단위).
러스트에서 흐름을 제어할수 있게 도와주는 가장 일반적인 구조는 if문과 루프 입니다.
if Expressions
if문은 조건에 따라서 코드를 분기할수 있도록 도와줍니다.
조건을 적은 다음, “이 조건에는 이 코드블록을 실행하고, 맞지 않으면 이 코드 블록을 실행해줘” 라고 적으면 됩니다.
branches 라는 새로운 프로젝트를 만든 다음(cargo new branches), if문을 체험해봅시다.
fn main() {
let number = 3;
if number < 5 {
println!("condition was true");
} else {
println!("condition was false");
}
}
condition was true
모든 if문은 if키워드로 시작합니다.
그리고 그 뒤에 조건을 붙입니다.
위 경우에서는, number변수가 5보다 작은지 작지않은지 체크합니다.
조건이 충족되었을때 실행하고 싶은 코드블록은 조건을 적은 바로 다음에 중괄호{} 안에 적습니다.
if 컨디션에 따라 실행되는, 관련된 코드 블록을 때로는 arms(팔) 이라고 불리기도 하는데,
챕터 2의 “Comparing the Guess to the Secret Number” section 에서 다루었던 match 표현식에서 나온 arm 과 같은 개념입니다.
추가적으로, else키워드도 사용할수 있는데,
프로그램의 조건이 충족되지않았을 경우, 대안을 주기 위해 선택할수 있습니다.
만약 else 키워드를 적지않았다면, 조건이 충족되지않았을때
그냥 if블록을 건너뛰고 다음 코드를 실행하게 됩니다.
만약 number의 값이 조건에 맞지 않는 값이라면 어떨까요?
let number = 7;
이번에는 결과가 이렇게 나오겠죠.
$ cargo run
Compiling branches v0.1.0 (file:///projects/branches)
Finished dev [unoptimized + debuginfo] target(s) in 0.31s
Running `target/debug/branches`
condition was false
그리고 조건문은 반드시 bool타입이어야 한다는 것을 명심하세요.
만약 조건이 bool이 아니라면 에러가 발생합니다.
다음과 같은 코드가 그 예입니다.
fn main() {
let number = 3;
if number {
println!("number was three");
}
}
if조건이 3이 되었고, 에러가 발생합니다.
$ cargo run
Compiling branches v0.1.0 (file:///projects/branches)
error[E0308]: mismatched types
--> src/main.rs:4:8
|
4 | if number {
| ^^^^^^ expected `bool`, found integer
error: aborting due to previous error
For more information about this error, try `rustc --explain E0308`.
error: could not compile `branches`
To learn more, run the command again with --verbose.
러스트는 bool 타입을 기대했지만 integer 타입이 들어왔기 때문에 에러가 발생했습니다.
루비와 자바스크립트와는 달리, 러스트는 자동으로 non-boolean 타입을 boolean 으로 바꾸지 않습니다.
항상 명시적으로 코드를 작성해야하며, if문에는 불리언(true or false)타입을 조건으로 넣어야합니다.
예를 들어, 숫자가 0이 아닐때만 if코드 블록을 실행하려면 다음과 같이 if문을 바꿔볼수 있을겁니다.
fn main() {
let number = 3;
if number != 0 {
println!("number was something other than zero");
}
}
위 코드를 실행하면 결과가 이렇게 나오겠죠: number was something other than zero .
Handling Multiple Conditions with else if
if와 else 를 결합하여 else if 문을 사용하면 여러 조건을 사용할수 있습니다.
fn main() {
let number = 6;
if number % 4 == 0 {
println!("number is divisible by 4");
} else if number % 3 == 0 {
println!("number is divisible by 3");
} else if number % 2 == 0 {
println!("number is divisible by 2");
} else {
println!("number is not divisible by 4, 3, or 2");
}
}
이 프로그램은 4가지 가능한 경우가 존재합니다.
결과는 다음과 같이 나올겁니다:
$ cargo run
Compiling branches v0.1.0 (file:///projects/branches)
Finished dev [unoptimized + debuginfo] target(s) in 0.31s
Running `target/debug/branches`
number is divisible by 3
프로그램이 실행되면, 각각의 if조건을 차례대로 보면서
가장 처음으로 조건을 충족하는 코드블록을 실행하게 됩니다.
주의할점은 6은 2로 나눠지지만, number is divisible by 2라는 결과는 물론,
else블록의 number is not divisible by 4,3, or 2 역시도 확인할수 없습니다.
러스트는 가장 처음으로 조건을 만족한 블록만 실행하기 때문입니다.
조건을 만족한 블록을 일단 찾으면, 나머지는 체크하지 않습니다.
else if표현식을 너무 많이 쓰면 코드가 복잡해질수 있기 때문에,
하나 이상의 조건을 써야하는 경우에는 코드를 리팩토링(refactor-최적화,수정)할 필요를 느낄수 있습니다.
이러한 경우를 위해, 챕터 6에서 match 라는 러스트의 강력한 분기 구조를 다룹니다.
Using if in a let Statement
if가 표현식이기 때문에, let문구 오른쪽에 사용할수도 있습니다. (복습: 표현식이란 일종의 함수 같은 것, 리턴값(결과값)이 있다.)
fn main() {
let condition = true;
let number = if condition { 5 } else { 6 };
println!("The value of number is: {}", number);
}
↑
Listing 3-2: Assigning the result of an if expression to a variable
number 이라는 변수에 if문의 결과값이 들어가게 됩니다.
아래와 같은 출력이 나오게 됩니다.
$ cargo run
Compiling branches v0.1.0 (file:///projects/branches)
Finished dev [unoptimized + debuginfo] target(s) in 0.30s
Running `target/debug/branches`
The value of number is: 5
코드 블록은 그 안에 있는 마지막 표현식으로 평가되며, 숫자 자체도 표현식이라는걸 기억하세요!
위 경우에서, 전체 if표현식의 값은 어떤 블록이 실행되느냐에 따라 결정됩니다.
이 말은 결국, if의 팔(arm-각 조건마다 지정된 블록)마다 나올수 있는 값들의 타입이 모두 같아야한다는 말과 같습니다;
위 코드에서 if블록과 else블록의 결과는 모두 i32정수 타입이었습니다.
타입이 다르면, 에러가 발생하게 됩니다.
fn main() {
let condition = true;
let number = if condition { 5 } else { "six" };
println!("The value of number is: {}", number);
}
위 코드의 경우, 에러가 나기 때문에 컴파일 할수 없습니다.
if블록와 else블록의 타입이 서로 비교 불가능하기때문에,
러스트는 정확히 어디를 봐야 문제를 찾을수 있는지를 알려줍니다:
$ cargo run
Compiling branches v0.1.0 (file:///projects/branches)
error[E0308]: `if` and `else` have incompatible types
--> src/main.rs:4:44
|
4 | let number = if condition { 5 } else { "six" };
| - ^^^^^ expected integer, found `&str`
| |
| expected because of this
error: aborting due to previous error
For more information about this error, try `rustc --explain E0308`.
error: could not compile `branches`
To learn more, run the command again with --verbose.

if블록의 표현식 결과가 정수로 나오고, else블록의 결과는 문자열로 나옵니다.
이렇게 되면 변수가 하나의 타입으로 통일되지 않았기 때문에 코드가 작동하지 않습니다.
러스트는 컴파일할때 number변수의 타입이 무엇인지 확실히 알아야하기때문에,
컴파일 하는 시점에 number변수가 사용되는 모든 곳에서 해당 변수의 타입이 유효한지 확인할수 있습니다.
number변수의 타입이 런타임(프로그램 실행)시에만 결정된다면 러스트는 위처럼 확인할수 없습니다;
컴파일러는 정해지지않은 타입들을 가진 여러 개의 변수를 추적해야할때 보다 복잡해지고, 코드에 대해 더 적은 보장을 하게됩니다(프로그램의 안전한 실행이 확실하지않음).