- 고전적인 커맨드 라인 검색 도구인 grep(globally search a regular expression and print)의 직접 구현한 버전을 만들어 보려고 합니다.
- 가장 단순한 사용례에서 grep은 어떤 특정한 파일에서 특정한 문자열을 검색합니다.
- 이를 위해 grep은 파일 경로와 문자열을 인수로 받습니다.
- 그다음 파일을 읽고, 그 파일에서 중 문자열 인수를 포함하고 있는 라인을 찾고, 그 라인들을 출력합니다.
- 수많은 다른 커맨드 라인 도구들이 사용하는 터미널의 기능을, 우리의 커맨드 라인 도구도 사용할 수 있게 하는 방법을 알아보겠습니다.
- 먼저 환경 변수의 값을 읽어서 사용자가 커맨드 라인 도구의 동작을 설정하도록 할 것.
- 또한
표준 출력 콘솔 스트림 (stdout)
대신 표준 에러 콘솔 스트림 (stderr)
에 에러 메시지를 출력하여,
- 예를 들자면 사용자가 화면을 통해 에러 메시지를 보는 동안에도
- 성공적인 출력을 파일로 리디렉션할 수 있게끔 할 것입니다.
1. 커멘트 라인 인수 받기
- 검색을 위한 문자열(searchstring), 그리고 검색하길 원하는 파일(example-filename.txt)을 사용할 수 있도록 하고 싶다는 것입니다:
$ cargo run -- searchstring example-filename.txt
- 현재 cargo new로 생성된 프로그램은 입력된 인수를 처리할 수 없습니다.
- crates.io에 있는 몇 가지 라이브러리가 커맨드 라인 인수를 받는 프로그램 작성에 도움 되겠지만, 지금은 이 개념을 막 배우는 중이므로 직접 이 기능을 구현해 봅시다.
- crates.io에 있는 몇 가지 라이브러리
- Crates.io는 Rust 커뮤니티가 공유하는 라이브러리들을 호스팅하는 공식 패키지 레지스트리
- 여기에서
'크레이트'라고 불리는 패키지
들을 찾고 설치할 수 있습니다.
- 크레이트는 다양한 기능을 제공하며, 여러분의 Rust 프로젝트에서 쉽게 사용할 수 있도록 패키징된 코드 묶음
1.1. 인수 값 읽기
- minigrep이 커맨드 라인 인수로 전달된 값들을 읽을 수 있도록 하기 위해서는
러스트의 표준 라이브러리
가 제공하는 std::env::args 함수
를 사용
- 이 함수는 minigrep으로 넘겨진 커맨드 라인 인수의 반복자 (iterator) 를 반환
- iterator의 collect 메서드를 호출하여
- 반복자가 생성하는 모든 요소를 담고 있는, 벡터 같은 컬렉션으로 바꿀 수 있다는 것
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
dbg!(args);
}
- Debug trait 포맷을 사용하여 값을 출력하는 그 밖의 방법은
dbg! 매크로
를 사용하는 것
- Debug 트레이트:
- 최종 사용자가 아닌, 개발자에게 유용한 방식으로 출력하여 디버깅하는 동안 값을 볼 수 있게 해주는 트레이트
dbg! 매크로
: 표현식(return 값이 있음)의 소유권을 가져와서, 표준 에러 콘솔 스트림(stderr)에 출력
- 참고
- println!
- 참조자를 사용 (소유권을 가져오지 않음)
- 표준 출력 콘솔 스트림(stdout)에 출력
- 러스트가 여러분이 원하는 종류의 컬렉션을 추론할 수는 없으므로,
- collect는 타입 표기가 자주 필요한 함수 중 하나
- 위 코드의 결과를 보면,
- 벡터의 첫 번째 값이 "target/debug/minigrep", 즉 이
바이너리 파일의 이름인 점
- 프로그램이 실행될 때 호출된 이름을 사용할 수 있게 해 줍니다.
- 프로그램의 이름에 접근할 수 있는 것은
- 메시지에 이름을 출력하고 싶을 때라던가
- 프로그램을 호출할 때 사용된 커맨드 라인 별칭이 무엇이었는지에 기반하여, 프로그램의 동작을 바꾸고 싶을 때 종종 편리하게 이용
1.1.1. args 함수와 유효하지 않은 유니코드
- 어떤 인수에라도 유효하지 않은 유니코드가 들어있다면 std::env::args가 패닉을 일으킨다는 점을 주의하세요.
- 만일 프로그램이 유효하지 않은 유니코드를 포함하는 인수들을 받을 필요가 있다면, std::env::args_os를 대신 사용하세요.
- 이 함수는 String 대신 OsString 값을 생성하는 반복자를 반환합니다.
- 여기서는 단순함을 위해 std::env::args을 사용했는데, 이는 OsString 값이 플랫폼 별로 다르고 String 값을 가지고 작업하는 것보다 더 복잡하기 때문입니다.
1.2. 인수 값들을 변수에 저장하기
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
let query = &args[1];
let file_path = &args[2];
println!("Searching for {}", query);
println!("In file {}", file_path);
}
2. 파일 읽기
use std::env;
use std::fs;
fn main() {
let args: Vec<String> = env::args().collect();
let query = &args[1];
let file_path = &args[2];
println!("In file {}", file_path);
let contents = fs::read_to_string(file_path)
.expect("Should have been able to read the file");
println!("With text:\n{contents}");
}
- contents:
std::io::Result<String>
- 일반적으로
함수 하나당 단 하나의 아이디어에 대한 기능을 구현
할 때 함수가 더 명료해지고 관리하기 쉬워짐
- 또 한 가지 문제: 처리 가능한 수준의 에러 처리를 안 하고 있다는 점
- 작은 양의 코드를 리팩터링하는 것이 훨씬 쉽기 때문에, 프로그램을 개발할 때 일찍 리팩터링하는 것은 좋은 관행
3. 모듈성과 에러 처리 향상을 위한 리팩터링
- 위 코드의 문제
- 첫 번째로는 main 함수가 지금 두 가지 일을 수행한다는 것입니다:
- 어떤 함수가 책임 소재를 계속 늘려나가면,
- 이 함수는 어떤 기능인지 추론하기 어려워지고,
- 테스트하기도 힘들어지고,
- 기능 일부분을 깨트리지 않으면서 고치기도 어려워짐
- 기능을 나누어 각각의 함수가 하나의 작업에 대한 책임만 지는 것이 최선
- main이 점점 길어질수록 필요한 변수들이 더 많이 스코프 안에 있게 되고,
- 스코프 안에 더 많은 변수가 있을수록 각 변수의 목적을 추적하는 것이 더 어려워짐
- 설정 변수들을 하나의 구조체로 묶어서 목적을 분명히 하는 것이 가장 좋습니다.
- 세 번째 문제는 파일 읽기 실패 시 에러 메시지 출력을 위해서 expect를 사용했는데, 파일을 읽는 작업은 여러 가지 방식으로 실패할 수 있습니다:
- 이를테면 파일을 못 찾았거나, 파일을 열 권한이 없었다든가 하는 식이죠.
- 네 번째로, expect가 서로 다른 에러를 처리하기 위해 반복적으로 사용되는데,
- 만일 사용자가 실행되기 충분한 인수를 지정하지 않고 프로그램을 실행한다면,
- 사용자는 러스트의 index out of bounds 에러를 얻게 될 것이고
- 이 에러는 문제를 명확하게 설명하지 못합니다.
- 모든 에러 처리 코드가 한 곳에 있어서
- 미래에 코드를 유지보수할 사람이 에러 처리 로직을 변경하기를 원할 경우
- 찾아봐야 하는 코드가 한 군데에만 있는 것이 가장 좋을 것
- 모든 에러 처리 코드를 한 곳에 모아두면 최종 사용자에게 의미 있는 메시지를 출력할 수 있음
3.0. 바이너리 프로젝트에 대한 관심사 분리
- 러스트 커뮤니티는 main이 커지기 시작할 때, 이 바이너리 프로그램의 별도 관심사를 나누기 위한 가이드라인을 개발했습니다.
- 이 프로세스는 다음의 단계로 구성되어 있습니다:
- 프로그램을 main.rs와 lib.rs로 분리하고 프로그램 로직을 lib.rs로 옮기세요.
커맨드 라인 파싱 로직
이 작은 동안에는 main.rs에 남을 수 있습니다.
커맨드 라인 파싱 로직
이 복잡해지기 시작하면, main.rs로부터 추출하여 lib.rs로 옮기세요.
- 이 과정을 거친 후 main 함수에 남아있는 책임소재는 다음으로 한정되어야 합니다:
- 인수 값을 가지고 커맨드 라인 파싱 로직 호출하기
- 그 밖의 설정
- lib.rs의 run 함수 호출
- run이 에러를 반환할 때 에러 처리하기
- 이 패턴은 관심사 분리에 관한 것입니다:
- main.rs는 프로그램의 실행을 다루고,
- lib.rs는 당면한 작업의 모든 로직을 처리
- main 함수를 직접 테스트할 수 없으므로,
- 이 구조는 lib.rs 내의 함수 형태로 테스트를 옮기게 하여 여러분의 모든 프로그램 로직을 테스트하게끔 합니다.
- main.rs에 남겨진 코드는 정확한지 검증할 때 읽는 것만으로도 충분할 정도로 작아질 것입니다.
3.1. 인수 파서 추출
fn main() {
let args: Vec<String> = env::args().collect();
let (query, file_path) = parse_config(&args);
}
fn parse_config(args: &[String]) -> (&str, &str) {
let query = &args[1];
let file_path = &args[2];
(query, file_path)
}
- main: 더 이상
커맨드 라인 인수와 변수들이 어떻게 대응되는지를 결정할 책임이 없
습니다.
3.2. 설정 값 묶기
- 현재는 튜플을 반환하는 중인데, 그런 다음 이 튜플을 개별 부분으로 즉시 다시 쪼개고 있습니다. 이는 아직 적절한 추상화가 이루어지지 않았다는 신호일 수 있습니다.
- 이 두 값을 하나의 구조체에 넣고 구조체 필드에 각각 의미가 있는 이름을 부여하려고 합니다.
- 그렇게 하는 것이 미래에 이 코드를 유지보수하는 사람에게
- 이 서로 다른 값들이 어떻게 연관되어 있고 이 값들의 목적은 무엇인지를 더 쉽게 이해하도록 만들어 줄 것
fn main() {
let args: Vec<String> = env::args().collect();
let config = parse_config(&args);
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
let contents = fs::read_to_string(config.file_path)
.expect("Should have been able to read the file");
}
struct Config {
query: String,
file_path: String,
}
fn parse_config(args: &[String]) -> Config {
let query = args[1].clone();
let file_path = args[2].clone();
Config { query, file_path }
}
- parse_config 본문에서는 원래 args의 String 값들을 참조하는 문자열 슬라이스를 반환했는데, 이제는 String 값을 소유한 Config를 정의
- main 안에 있는 args 변수는 인수 값들의 소유자이고,
- parse_config 함수에게는 이 값을 빌려주고 있을 뿐인데,
- 이는 즉 Config가 args의 값에 대한 소유권을 가져가려고 하면 러스트의 대여 규칙을 위반하게 된다는 의미
- String 데이터를 관리하는 방법은 다양하며, 가장 쉬운 방법은 (다소 비효율적이지만) 그 값에서 clone 메서드를 호출하는 것
- 이는 데이터의 전체 복사본을 만들어 Config 인스턴스가 소유할 수 있게 해주는데, 이는 문자열 데이터에 대한 참조자를 저장하는 것에 비해 더 많은 시간과 메모리를 소비
- 그러나 값의 복제는 참조자의 라이프타임을 관리할 필요가 없어지기 때문에 코드를 매우 직관적으로 만들어 주기도 하므로,
- 이러한 환경에서 약간의 성능을 포기하고 단순함을 얻는 것은 가치 있는 절충안
3.2.1. clone을 사용한 절충안
- 러스타시안들 중에서 많은 이들이 런타임 비용의 이유로, clone을 사용한 소유권 문제 해결을 회피하는 경향
- 하지만 프로젝트를 계속 진행하기 위해 지금으로서는 약간의 문자열을 복사하는 정도는 괜찮은데, 이 복사가 딱 한 번만 일어나고 파일 경로와 질의 문자열이 매우 작기 때문
- 한 번에 매우 최적화된 코드 작성을 시도하기보다는 다소 비효율적이라도 동작하는 프로그램을 만드는 편이 좋습니다.
- 여러분이 러스트에 더 경험을 쌓게 되면 가장 효율적인 해답을 가지고 시작하기 더 쉽겠으나, 지금으로선 clone을 호출하는 것도 충분히 허용될만 합니다.
3.3. Config를 위한 생성자 만들기
- parse_config 함수의 목적:
Config 인스턴스를 생성하는 것
- parse_config를 일반 함수에서 -> Config 구조체와 연관된 new라는 이름의 함수로 바꿀 수 있음
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args);
}
impl Config {
fn new(args: &[String]) -> Config {
let query = args[1].clone();
let file_path = args[2].clone();
Config { query, file_path }
}
}
3.4. 에러 처리 수정
- args 벡터에 3개보다 적은 아이템이 들어있는 경우에는
- 인덱스 1이나 2의 값에 접근을 시도하는 것이 프로그램의 패닉을 일으킬 것이라는 점
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished dev [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep`
thread 'main' panicked at 'index out of bounds: the len is 1 but the index is 1', src/main.rs:27:21
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
- index out of bounds: the len is 1 but the index is 1 줄은 프로그래머를 위한 에러 메시지입니다.
- 최종 사용자들에게는 무엇을 대신 해야 하는지 이해시키는 데 도움이 안 될 것입니다.
3.4.1. 에러 메시지 개선
fn new(args: &[String]) -> Config {
if args.len() < 3 {
panic!("not enough arguments");
}
- panic!을 호출하는 것은 사용의 문제보다는 프로그램의 문제에 더 적합합니다.
- 즉 성공인지 혹은 에러인지를 나타내는 Result를 반환하는 기술을 사용해 보겠습니다.
3.4.2. panic! 호출 대신 Result 반환하기
- new라는 함수 이름은 build로 변경할 것인데,
- 이는 많은 프로그래머가 new 함수가 절대 실패하지 않으리라 예상하기 때문입니다.
- Config::build가 main과 소통하고 있을 때, Result 타입을 사용하여 문제에 대한 신호를 줄 수 있습니다. - 그러면 main을 수정하여 Err 배리언트를 사용자에게 더 실용적인 에러 메시지로 변경할 수 있고,
- 이는 panic!의 호출로 인한 thread 'main'과 RUST_BACKTRACE에 대해 감싸져 있는 텍스트를 없앨 수 있겠습니다. (불필요한 에러 메시지를 보지 않아도 된다!)
impl Config {
fn build(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let file_path = args[2].clone();
Ok(Config { query, file_path })
}
}
3.4.3. Config::build 호출과 에러 처리
- main을 수정하여 Config::build에 의해 반환되는 Result를 처리할 필요가 있음
- 또한 panic!으로부터 벗어나서 직접 0이 아닌 에러 코드로 커맨드 라인 도구를 종료하도록 구현할 것
- 0이 아닌 종료 상태값은 프로그램을 호출한 프로세스에게 에러 상태값과 함께 종료되었음을 알려주는 관례
use std::process;
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::build(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {err}");
process::exit(1);
});
- unwrap_or_else을 사용하면 커스터마이징된 (panic!이 아닌) 에러 처리를 정의할 수 있습니다.
- 만일 Result가 Ok 값이라면, 이 메서드의 동작은 unwrap과 유사합니다:
- 하지만 값이 Err 값이라면,
- 이 메서드는 클로저 (closure) 안의 코드를 호출 (위 코드에서는 print와 exit 2줄)하는데,
- 이는 unwrap_or_else의 인수로 넘겨준 우리가 정의한 익명 함수
Err의 내부 값
을 클로저의 세로 파이프 (|) 사이에 있는 err 인수
로 넘겨주는데,
- 이번 경우 그 값은 예제 12-9에 추가한 정적 문자열 "not enough arguments"
3.5. main으로부터 로직 추출하기
- 현재 main 함수에 있는 로직 중
설정 값이나 에러 처리
와는 관련되지 않은 모든 로직을
fn main() {
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
run(config);
}
fn run(config: Config) {
let contents = fs::read_to_string(config.file_path)
.expect("Should have been able to read the file");
println!("With text:\n{contents}");
}
3.5.1. run 함수로부터 에러 반환하기
- run 함수로 분리된 남은 프로그램 로직에 대하여, 에러 처리 기능을 개선할 수 있습니다.
- run 함수는 뭔가 잘못되면 expect를 호출하여 프로그램이 패닉이 되도록 하는 대신
- Result<T, E>를 반환할 것
- 참고: expect:
- Result 타입의 도우미 메서드로, Err 배리언트인 경우 원하는 에러 메시지와 함께 panic! 을 발생시킴
use std::error::Error;
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
println!("With text:\n{contents}");
Ok(())
}
- 에러 타입에 대해서는 트레이트 객체
Box<dyn Error>
를 사용했습니다 (그리고 상단에 use 구문을 사용하여 std::error::Error를 스코프로 가져 왔습니다).
- ?
- ? 연산자를 사용할 때의 에러 값들은 from 함수를 거친다는 것
- from 함수: 표준 라이브러리 내의 From 트레이트에 정의
- 어떤 값의 타입을 다른 타입으로 변환하는 데에 사용
- ? 연산자가 from 함수를 호출하면,
- ? 연산자가 얻게 되는 에러를, ? 연산자가 사용된 현재 함수의 반환 타입에 정의된 에러 타입으로 변환
- 이는 어떤 함수가 다양한 종류의 에러로 인해 실패할 수 있지만, 모든 에러를 하나의 에러 타입으로 반환할 때 유용
Box<dyn Error>
는
- 이 함수가
Error 트레이트를 구현한 어떤 타입을 반환
하는데,
- 그 반환 값이 구체적으로 어떤 타입인지는 특정하지 않아도 된다는 것을 의미
- 이는 서로 다른 에러의 경우에서, 서로 다른 타입이 될지도 모를 에러값을 반환하는 유연성을 제공
- dyn 키워드는 ‘동적 (dynamic)’의 줄임말입니다.
- ()를 사용하는 것은 run을 호출하여 부작용에 대해서만 처리하겠다는 것을 가리키는 자연스러운 방식
3.5.2. main에서 run으로부터 반환된 에러 처리하기
fn main() {
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
if let Err(e) = run(config) {
println!("Application error: {e}");
process::exit(1);
}
}
- if let
- 하나의 패턴만 매칭시키고 나머지 경우는 무시하도록 값을 처리하는 간결한 방법을 제공
- Enum type에 대해, match 표현식을 쓰는 대신, 대안으로 if let을 쓸 수 있음
- unwrap_or_else 대신 if let이 사용되었습니다.
- run 함수가 반환한 값은 unwrap을 하지 않아도 됩니다.
- run이 성공한 경우 ()를 반환하기 때문에 에러를 찾는 것만 신경 쓰면 되므로,
- 고작 ()나 들어있을 값을 반환하기 위해 unwrap_or_else를 쓸 필요는 없어집니다.
3.6. 라이브러리 크레이트로 코드 쪼개기
- main 함수가 아닌 모든 코드를 src/main.rs에서 src/lib.rs로 옮깁시다:
- run 함수 정의 부분
- 이와 관련된 use 구문들
- Config 정의 부분
- Config::build 함수 정의 부분
- src/lib.rs
use std::error::Error;
use std::fs;
pub struct Config {
pub query: String,
pub file_path: String,
}
impl Config {
pub fn build(args: &[String]) -> Result<Config, &'static str> {
}
}
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
}
use std::env;
use std::process;
use minigrep::Config;
fn main() {
if let Err(e) = minigrep::run(config) {
}
}
4. 테스트 주도 개발로 라이브러리 기능 개발하기
- 코드의 핵심 기능에 대한 테스트를 작성하기 무척 쉽습니다.
- 커맨드 라인에서 바이너리를 호출할 필요 없이 다양한 인수 값으로 함수를 직접 호출하여 반환 값을 검사해 볼 수 있습니다.
- 아래의 단계를 따르는 테스트 주도 개발 (Test-Driven Development, TDD) 프로세스
- 실패하는 테스트를 작성하고 실행하여, 여러분이 예상한 이유대로 실패하는지 확인
- 이 새로운 테스트를 통과하기 충분한 정도의 코드만 작성하거나 수정
- 추가하거나 변경한 코드를 리팩터링하고 테스트가 계속 통과하는지 확인
- 1단계로 돌아가세요!
- TDD는 코드 설계를 주도하는데 도움이 됩니다.
- 테스트를 통과하도록 해줄 코드를 작성하기 전에 테스트 먼저 작성하는 것은
- 프로세스 전체에 걸쳐 높은 테스트 범위를 유지하는 데 도움을 줍니다.
4.1. 실패하는 테스트 작성하기
- search라는 이름의 함수에 추가해 보겠습니다.
- src/lib.rs에 test 모듈과 함께 테스트 함수를 추가하세요. 테스트 함수는 search 함수가 가져야 할 동작을 지정
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_result() {
let query = "duct";
let contents = "\
Rust:
safe, fast, productive.
Pick three.";
assert_eq!(vec!["safe, fast, productive."], search(query, contents));
}
}
- 앞의 큰 따옴표 뒤에 붙은 역슬래시는
- 이 문자열 리터럴 내용의 앞에 줄 바꿈 문자를 집어넣지 않도록 러스트에게 알려주는 것임을 유의
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
vec![]
}
- search의 시그니처에는 명시적 라이프타임 'a가 정의될 필요가 있고
- 이 라이프타임이 contents 인수와 반환 값에 사용되고 있음을 주목
- 바꿔 말하면, 지금 러스트에게 search 함수에 의해 반환된 데이터가
- search 함수의 contents 인수로 전달된 데이터만큼 오래 살 것이라는 것을 말해준 것
- 이것이 중요합니다!
- 슬라이스에 의해 참조된 데이터는 그 참조자가 유효한 동안 유효할 필요가 있습니다;
4.2. 테스트를 통과하도록 코드 작성하기
- search를 구현하려면 프로그램에서 아래의 단계를 따라야 합니다:
- 내용물의 각 라인에 대해 반복
- 해당 라인이 질의 문자열을 담고 있는지 검사
- 만일 그렇다면, 반환하고자 하는 값의 리스트에 추가
- 아니라면 아무것도 안 합니다.
- 매칭된 결과 리스트를 반환합니다.
4.2.1. lines 메서드로 라인들에 대해 반복하기
- 러스트는 문자열의 라인별 반복을 처리하기 위한 유용한 메서드를 제공하는데, 편리하게도 lines라는 이름
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
for line in contents.lines() {
}
}
4.2.2. 각 라인에서 질의값 검색하기
- 현재의 라인에 질의 문자열이 들어있는지 검사해 보겠습니다.
- 다행히도 이걸 해주는 contains라는 이름의 유용한 메서드가 문자열에 있습니다!
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
for line in contents.lines() {
if line.contains(query) {
}
}
}
4.2.3. 매칭된 라인 저장하기
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
let mut results = Vec::new();
for line in contents.lines() {
if line.contains(query) {
results.push(line);
}
}
results
}
4.2.4. run 함수에서 search 함수 사용하기
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
for line in search(&config.query, &contents) {
println!("{line}");
}
Ok(())
}
5. 환경 변수 사용하기
- 환경 변수를 통해 사용자가 켤 수 있는 대소문자를 구분하지 않는 검색 옵션
- 이 기능을 커맨드 라인 옵션으로 만들어서 필요한 경우 사용자가 매번 입력하도록 요구할 수도 있겠으나,
- 환경 변수로 만듦으로써
- 사용자는 이 환경 변수를 한 번만 설정하고 난 다음
- 그 터미널 세션 동안에는 모든 검색을 대소문자 구분 없이 할 수 있게 됩니다.
5.1. 대소문자를 구분하지 않는 search 함수에 대한 실패하는 테스트 작성하기
5.2. search_case_insensitive 함수 구현하기
- query와 각 line을 소문자로 만들어서
- 입력된 인수의 대소문자가 어떻든 간에 질의어가 라인에 포함되어 있는지 확인할 때는 언제나 같은 소문자일 것이란 점
pub fn search_case_insensitive<'a>(
query: &str,
contents: &'a str,
) -> Vec<&'a str> {
let query = query.to_lowercase();
let mut results = Vec::new();
for line in contents.lines() {
if line.to_lowercase().contains(&query) {
results.push(line);
}
}
results
}
- to_lowercase가 기본적인 유니코드를 처리하겠지만, 100% 정확하지는 않을 것입니다.
- 실제 애플리케이션을 작성하는 중이었다면 여기에 약간의 작업을 추가할 필요가 있겠지만, 이 절은 유니코드가 아니라 환경변수에 대한 것이므로, 여기서는 그대로 두겠습니다.
- to_lowercase의 호출이 존재하는 데이터를 참조하지 않고 새로운 데이터를 만들기 때문에,
- query가
- 이제 문자열 슬라이스(&str)가 아니라
- String이 되었음을 주의하세요.
- 문자열 슬라이스(&str)
- 문자열(String)의 일부분을 참조
- 문자열 데이터의 위치와 길이를 저장하여, 원본 문자열 데이터를 참조
- 문자열 리터럴도 포함
- 문자열 (String)
- heap에 저장되는 가변 문자열
- tring은 str 데이터를 소유(포함)
- 이제 query를 contains의 인수로 넘길 때는 앰퍼센드를 붙여줄 필요가 있는데,
- 이는 contains의 시그니처가 문자열 슬라이스를 받도록 정의되어 있기 때문
- 대소분자 구분 여부를 전환하기 위한 옵션을 Config 구조체에 추가하겠습니다.
- src/lib.rs
pub struct Config {
pub query: String,
pub file_path: String,
pub ignore_case: bool,
}
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
let results = if config.ignore_case {
search_case_insensitive(&config.query, &contents)
} else {
search(&config.query, &contents)
};
for line in results {
println!("{line}");
}
Ok(())
}
- 환경 변수 사용을 위한 함수는 표준 라이브러리의 env 모듈에 있으므로, src/lib.rs 상단에서 이 모듈을 스코프로 가져옵니다
use std::env;
impl Config {
pub fn build(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let file_path = args[2].clone();
let ignore_case = env::var("IGNORE_CASE").is_ok();
Ok(Config {
query,
file_path,
ignore_case,
})
}
}
env::var("IGNORE_CASE")
은 Result를 반환
- 여기에는 해당 환경 변수에 어떤 값이 설정되어 있을 경우 그 값을 담은 Ok 배리언트가 될 것입니다.
- 만일 환경 변수가 설정되어 있지 않다면 Err 배리언트가 반환될 것입니다.
- 만일 IGNORE_CASE 환경 변수가 아무 값도 설정되어 있지 않다면, is_ok는 거짓값을 반환하고 프로그램은 대소문자를 구분하는 검색을 수행할 것입니다.
- 이 환경 변수의 값에 대해서는 고려하지 않고 그저 값이 설정되어 있는지 아닌지만 고려하므로,
- 여기서는 unwrap이나 expect 혹은 Result에서 사용했던 다른 메서드들 대신
- is_ok를 사용하고 있습니다.
$ IGNORE_CASE=1 cargo run -- to poem.txt
- 어떤 프로그램들은 같은 환경값에 대해 인수와 환경 변수 모두를 사용할 수 있게 합니다.
- 그러한 경우에는 보통 한쪽이 다른 쪽에 대해 우선순위를 갖도록 결정합니다.
- std::env 모듈에는 환경 변수를 다루기 위한 더 유용한 기능들을 많이 가지고 있습니다.
6. 표준 출력 대신, 표준 에러로 에러 메시지 작성하기
- 이 시점에서는 터미널로 출력되는 모든 것이 println! 매크로를 사용하여 작성되고 있는 상태
- 대부분의 터미널에는 두 종류의 출력이 있습니다:
- 범용적인 정보를 위한 표준 출력 (standard output) (stdout)과
- 에러 메시지를 위한 표준 에러 (standard error) (stderr)
- 이러한 구분은, 사용자로 하여금 아래 처럼 해줄 수 있습니다.
성공한 프로그램의 출력값을 파일로 향하게끔
에러 메시지는 여전히 화면에 나타나도록
- println! 매크로는 표준 출력으로의 출력 기능만 있음
6.1. 에러가 기록되었는지 검사하기
- 먼저 minigrep이 출력하는 내용들이
- 현재 표준 에러 쪽에 출력하고 싶은 에러 메시지를 포함하여
- 어떤 식으로 표준 출력에 기록되는지 관찰해 봅시다.
- 의도적으로 에러를 발생시키면서 표준 출력 스트림을 파일 쪽으로 리디렉션하여 이를 확인할 것입니다.
- 표준 에러 스트림은 리디렉션하지 않을 것이므로
- 표준 에러 쪽으로 보내진 내용들은 계속 화면에 나타날 것입니다.
- 커맨드 라인 프로그램은 표준 에러 스트림 쪽으로 에러 메시지를 보내야 하므로
- 표준 출력 스트림이 파일로 리디렉션되더라도 여전히 에러 메시지는 화면에서 볼 수 있습니다.
- 이 동작을 확인해 보기 위해서 프로그램을 >과 파일 경로 output.txt과 함께 실행해 보려 하는데,
이 파일 경로는 표준 출력 스트림이 리디렉션될 곳입니다.
- 아무런 인수를 넣지 않을 것인데, 이는 에러를 발생시켜야 합니다:
$ cargo run > output.txt
- > 문법은 셸에게 표준 출력의 내용을 화면 대신 output.txt에 작성하라고 알려줍니다.
- output.txt
Problem parsing arguments: not enough arguments
- 에러 메시지가 표준 출력에 기록되고 있네요.
- 이런 종류의 에러 메시지는 표준 에러로 출력되게 함으로써
- 성공적인 실행으로부터 나온 데이터만 파일로 향하게 만드는 것이 훨씬 유용합니다
6.2. 표준 에러로 에러 출력하기
- 에러 메시지를 출력하는 모든 코드는 단 하나의 함수 main 안에 있습니다.
- 표준 라이브러리는 표준 에러 스트림으로 출력하는 eprintln! 매크로를 제공하므로,
- 에러 출력을 위해 println!을 호출하고 있는 두 군데를 eprintln!로 바꿔봅시다.
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::build(&args).unwrap_or_else(|err| {
eprintln!("Problem parsing arguments: {err}");
process::exit(1);
});
if let Err(e) = minigrep::run(config) {
eprintln!("Application error: {e}");
process::exit(1);
}
}
$ cargo run > output.txt
Problem parsing arguments: not enough arguments
- 다시 한번 프로그램을 시키는데 이번에는 다음과 같이 에러를 내지 않는 인수를 사용하고 표준 출력을 파일로 리디렉션 시켜봅시다:
cargo run -- to poem.txt > output.txt
- 터미널에는 아무런 출력을 볼 수 없고, output.txt에는 결과물이 담겨 있을 것입니다: