[rust] 12. I/O 프로젝트: 커멘드 라인 프로그램 만들기

About_work·2024년 7월 28일
0

rust

목록 보기
14/16
  • 고전적인 커맨드 라인 검색 도구인 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 커뮤니티가 공유하는 라이브러리들을 호스팅하는 공식 패키지 레지스트리
    • 여기에서 '크레이트'라고 불리는 패키지들을 찾고 설치할 수 있습니다.
      • 1 패키지: n 크레이트
    • 크레이트는 다양한 기능을 제공하며, 여러분의 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. 파일 읽기

  • src/main.rs
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과 유사합니다:
    • 즉 Ok가 감싸고 있는 안쪽 값을 반환
  • 하지만 값이 Err 값이라면,
    • 이 메서드는 클로저 (closure) 안의 코드를 호출 (위 코드에서는 print와 exit 2줄)하는데,
      • 이는 unwrap_or_else의 인수로 넘겨준 우리가 정의한 익명 함수
    • Err의 내부 값클로저의 세로 파이프 (|) 사이에 있는 err 인수로 넘겨주는데,
      • 이번 경우 그 값은 예제 12-9에 추가한 정적 문자열 "not enough arguments"

3.5. main으로부터 로직 추출하기

  • 현재 main 함수에 있는 로직 중 설정 값이나 에러 처리와는 관련되지 않은 모든 로직을
    • run이라는 함수로 추출하도록 하겠습니다.
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>> {
    // --생략--
}
  • src/main.rs
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() {
        // do something with line
    }
}
  • lines 메서드는 iterator를 반환

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) {
            // do something with line
        }
    }
}

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에는 결과물이 담겨 있을 것입니다:
profile
새로운 것이 들어오면 이미 있는 것과 충돌을 시도하라.

0개의 댓글