Rust - 커맨드라인 Argument 파싱하기 - (1)

숲사람·2022년 8월 22일
0

Rust

목록 보기
14/15

적어도 시스템 프로그램이라고 하면 커맨드라인 인자를 받아야만 뭔가 완성되는것 같다. 그래서 애초에 프로그램을 작성하기 전에 인자를 전달받는 템플릿을 미리 생성해놓은 뒤 메인로직 작성을 시작하는게 편하다. Rust에서는 어떻게 커맨드라인 인자를 받고 사용할 수 있을까? 이 글에서는 Rust에서 커맨드라인을 어떻게 입력받는지 살펴 보려고 한다. 그리고 다음 글에서 Rust에서 커맨드라인 argument parser로 많이 사용되는 crate인 Clap 사용법에대해 알아볼것이다.

C, Bash, Python

그전에 C와 Bash 그리고 Python에서는 어떻게 인자를 전달받는지 비교해보자. 참고로 여기서는 argument parsing 도구나 프레임웍을 사용하지 않고 나이브하게 받는 방법을 살펴본다.

우선 C는 아래와 같이 main()함수의 인자로 커맨드라인 argument를 전달받는다. argc는 인자의 갯수이고, argv는 전달받은 인자의 문자열이 담긴 문자열 배열이다.

/* file name: args.c */
#include <stdio.h>
int main(int argc, char *argv[]) 
{
	for (int i = 0; i < argc; i++)
    	printf("arg[%d]: %s\n", i, argv[i]);
	return 0;
}

컴파일 하고 실행해 보면 아래와 같다. 0번째 argument는 항상 실행파일 이름이다. 이것은 C에서 뿐만아니라 Rust포함 모든 언어에서 마찬가지다.

 $ gcc args.c
 $ ./a.out hello argument parsing
arg[0]: ./a.out
arg[1]: hello
arg[2]: argument
arg[3]: parsing

그러면 Bash shell script에서는 인자를 어떻게 전달받을까? 매우 쉽다. $0, $1, $2 ... 를 사용하면 된다. 이는 쉘스크립의 함수에서도 동일하게 인자로 사용된다.

# file name: args.sh
#! /bin/bash

echo "$0"
echo "$1"
echo "$2"
echo "$3"

실행하면

$ ./args.sh hello argument parsing
./args.sh
hello
argument
parsing

Python에서는 아래와 같이 sys.argv[0] sys.argv[1] 로 인자를 전달받을 수 있다. sys.argv는 Iterator 타입 이기 때문에 아래와 같이 for in 문으로 출력 가능하다.

# file name: args.py
import sys 

for arg in sys.argv:
    print("%s" %(arg))

실행하면 아래와 같다.

$ python3 args.py hello argument parsing
args.py
hello
argument
parsing

Rust

자 이제 Rust에서 어떻게 커맨드라인 Argument를 전달받는지 살펴보자.

std::env::args()

use std::env 를 통해 std표준 라이브러리의 std::env::args() 함수를 사용한다. 이 args() 는 Iterator를 반환한다. 따라서 아래와 같이 for in 문을 통해 값에 접근할 수 있다.

/* file name args.rs */
use std::env;

fn main() {
    for a in env::args() {
        print!("{a} ");
    }   
}

인자를 3개 전달하여 실행하면 결과는 아래와 같이 4개가 나온다. Rust도 마찬가지로 arg0은 항상 실행파일명이다.

$ cargo build
$ target/debug/args hello argument parsing
target/debug/args hello argument parsing

Rust에서 Iterator는 collect() 함수를 호출하여 일련의 값을 벡터로 변환 할 수 있다.

/* file name args.rs */
use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();
    println!("{:?}", args);
}

출력

$ cargo run hello argument parsing
["target/debug/args", "hello", "argument", "parsing"]

보다 Rusty 하게 Argument 처리하기

여기 부터는 RustBook 12장 An I/O Project: Building a Command Line Program 을 참고한 내용이다. 만약 작성하는 프로그램에서 직접 Argument를 받아서 처리할 예정이라면 아래의 내용을 참고해 템플릿 코드를 작성하면 될 것이다.

아래 내용은 RustBook 에 소개된 보다 Rust스럽게 Argument를 전달받는 방법이다. 우선 Config 구조체를 만들고 전달받을 매개변수를 정의한다. 그리고 struct Confignew() 함수(associated fuction)로, 매개변수를 받아 Config{} 인스턴스를 리턴하는 함수를 작성한다. 그 뒤 Config::new()에 생성한 argument Vector를 레퍼런스로 전달하면 config 인스턴스로 각 매개변수를 확인할 수 있다.

내 생각에 Rusty한 코드는 에러 핸들링이 잘 구현된 한 코드인것 같다. new() Asociate함수는 Result<Config, String> 타입을 반환한다. 이는 caller에서 unwrap_or_else()로 핸들링 하였다. unwrap_or_else()은 Ok() 이면 정상적으로 Ok(Config) 타입을 반환하지만, Err()일때는 Err(String) 타입을 반환한다. 이는 unwrap_or_else()에서 익명함수 Closure를 통해 에러메시지 String을 직접 정리할 수 있다.

use std::env;
use std::process;

struct Config {
    option: String,
    query: String,
    filename: String,
}

impl Config {
    fn new(aa: &Vec<String>) -> Result<Config, String> {
        if aa.len() != 4 {
            return Err("Must pass 3 arguments".to_string());
        }
        let arg1 = aa[1].clone(); 
        // 여기서 aa는 reference로 빌려왔기 때문에 move될 수 없다. 따라서 clone()으로 copy해야한다. 
        let arg2 = aa[2].clone();
        let arg3 = aa[3].clone();
        Ok(Config {
            option: arg1,
            query: arg2,
            filename: arg3,
        })
    }
}

fn main() {
    let args: Vec<String> = env::args().collect();
    let config = Config::new(&args).unwrap_or_else(|err| {
        println!("ERROR: {err}");
        process::exit(1);
    });
    println!("{}, {}, {}", config.option, config.query, config.filename);
}

실행하면 아래와 같다.

 $ cargo build
 $ target/debug/cli_args hello argument parsing
hello, argument, parsing

추가 Refectoring

Rust는 main함수에서 메인 로직을 호출하는 정도의 역할만 수행하도록 장려한다. 메인 로직은 lib.rs 파일로 옮기는것이 좋다. lib.rs 파일명은 자동으로 인식된다. Rust 문서에 따르면 main함수가 해야할 역할은 다음과 같다.

  • Calling the command line parsing logic with the argument values
  • Setting up any other configuration
  • Calling a run function in lib.rs
  • Handling the error if run returns an error

따라서 위의 코드를 lib.rs파일과 main.rs파일로 분리해 보자.

main.rs

main.rs 에서는 Argument 를 전달받고 구조체를 초기화하는 함수를 호출하고, 메인로직 함수 run()을 호출하는 정도만 수행한다. main에 use 프로젝트이름을 추가했다use cli_args. 그리고 cli_args:: 로 부터 Config::new()run()을 호출했다.

use std::env;
use std::process;
use cli_args;

fn main() {
    let args: Vec<String> = env::args().collect();
    let config = cli_args::Config::new(&args).unwrap_or_else(|err| {
        println!("ERROR: {err}");
        process::exit(1);
    }); 
    cli_args::run(config);
}

lib.rs

struct Config 데이터와 메소드, 그리고 메인 로직은 lib.rs로 옮겨왔다. 외부 파일에서 참조할 수 있도록 pub 키워드가 추가된것에 유의하자.

pub struct Config {
    option: String,
    query: String,
    filename: String,
}   
        
impl Config {
    pub fn new(aa: &Vec<String>) -> Result<Config, String> {
        if aa.len() != 4 {
            return Err("Must pass 3 arguments".to_string());
        }
        let arg1 = aa[1].clone();
        let arg2 = aa[2].clone();
        let arg3 = aa[3].clone();
        Ok(Config {
            option: arg1,
            query: arg2,
            filename: arg3,
        })
    }
}

pub fn run(config: Config) {
    println!("{}, {}, {}", config.option, config.query, config.filename);
}

참고

profile
기록 & 정리 아카이브용

0개의 댓글