3
\$\begingroup\$

I just started learning Rust and my first project is a command line tool generating a pairwise distance matrix for binary data while ignoring missing values. The input files look like so:

001N0
011N0
10N10

with N denoting missing values that should be ignored. The example yields

0,1,1
1,0,2
1,2,0

After parsing the command line args with clap each sample (i.e. line in input file) is parsed into a struct BitArrNA holding 2 bitvec; one called bits for the data (with N as 0) and one called not_nas specifying the positions of not-NA-values (0 if N, else 1). To get the hamming distance between two BitArrNA three bitwise operations are necessary: I chose ^ between the two bits, & between the two not_nas and & between the two results. All samples are compared with each other and the distances are stored in a triangular matrix (a vector of vectors) in the struct TriMat before being written as comma-separated symmetric matrix to the output file.

Errors

After some reading about error handling philosophies in Rust, I decided to go with two strategies:

  • Errors caused by invalid input are handled with unwrap_or_else and std::process::exit
  • Other errors indicate internal bugs in my code/logic and should throw a panic with expect

Questions

  • What could have been done in a more idiomatic way?
  • Are there any low-hanging fruits in terms of performance / efficiency / concurrency?
  • Is the error-handling suitable?

Code

main.rs

use binary_hamming_dist::bitarr::BitArrNa;
use binary_hamming_dist::cli::parse_cmd_line;
use binary_hamming_dist::trimat::TriMat;
use indicatif::{ProgressBar, ProgressStyle};
use rayon::prelude::*;
use std::fs;
use std::io;
use std::io::BufRead;

type Dist = u32;

fn main() {
    // get command line arguments
    let (infname, na_char, output, threads): (String, char, Option<String>, usize) =
        parse_cmd_line();

    // create thread pool
    rayon::ThreadPoolBuilder::new()
        .num_threads(threads)
        .build_global()
        .expect("Error initializing threadpool");

    // parse file into bitarr vec
    let mut bitarrs: Vec<BitArrNa> = Vec::new();
    let infile = fs::File::open(infname).unwrap_or_else(|err| {
        eprintln!("Error opening input file: {}", err);
        std::process::exit(1);
    });
    let infile = io::BufReader::new(infile);

    for (i, line) in infile.lines().enumerate() {
        if let Ok(line) = line {
            bitarrs.push(BitArrNa::from_string(&line, na_char).unwrap_or_else(|err| {
                eprintln!("Error generating bitarr at line {}: {}", i + 1, err);
                std::process::exit(1);
            }));
        } else {
            eprintln!("Error reading input file at line {}", i + 1);
            std::process::exit(1);
        }
    }

    // create Vec of Vecs for holding the distances (resembling a triangular matrix)
    let n = bitarrs.len();
    let mut dists: TriMat<Dist> = TriMat::new(n - 1);

    // setup progress bar
    let pb = ProgressBar::new(n as u64);
    pb.set_style(
        ProgressStyle::default_bar()
            .template("[{elapsed_precise}] [{bar:50.cyan/blue}] {pos}/{len} ({eta})")
            .progress_chars("#>-"),
    );

    // get the distances
    dists
        .mat
        .par_iter_mut()
        .zip(bitarrs[..n - 1].par_iter()) // skip last sample
        .enumerate()
        .for_each(|(i, (dists_i, bitarr_i))| {
            for bitarr_j in &bitarrs[i + 1..] {
                dists_i.push(bitarr_i.dist(bitarr_j));
            }
            pb.inc(1);
        });
    pb.finish_with_message("done");

    // write result to file
    if let Some(outfname) = output {
        let mut file = fs::File::create(&outfname).unwrap_or_else(|err| {
            eprintln!("Error opening output file: {}", err);
            std::process::exit(1);
        });
        dists.write_symmetric(&mut file);
        println!("Result written to {}", outfname);
    } else {
        dists.write_symmetric(&mut io::stdout());
    }
}

lib.rs -- only declaring modules for other lib files

pub mod bitarr;
pub mod cli;
pub mod trimat;

cli.rs -- holds function for command line parsing

pub fn parse_cmd_line() -> (String, char, Option<String>, usize) {
    let matches = clap::App::new("Binary Hamming Distance Calculator")
        .about(
            "Calculates the pairwise distance matrix of binary strings and \
             tolerates missing values. \n\
             The input file should hold one sample per line and look like: \n\n\
             001X0 \n\
             011X0 \t where 'X' denotes a missing value. This yields \n\
             10X10 \n\n\
             0,1,1 \n\
             1,0,2 \t as result.\n\
             1,2,0 \n",
        )
        .version(clap::crate_version!())
        .arg(
            clap::Arg::with_name("input")
                .help("input file")
                .takes_value(true)
                .short("i")
                .long("input")
                .required(true)
                .value_name("FILE")
                .display_order(1),
        )
        .arg(
            clap::Arg::with_name("NA-char")
                .help("the character [A-Za-z2-9] specifying missing values")
                .takes_value(true)
                .default_value("X")
                .possible_values(&[
                    "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P",
                    "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "a", "b", "c", "d", "e", "f",
                    "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v",
                    "w", "x", "y", "z", "2", "3", "4", "5", "6", "7", "8", "9",
                ])
                .hide_possible_values(true)
                .short("n")
                .long("NA-value")
                .value_name("CHAR")
                .display_order(2),
        )
        .arg(
            clap::Arg::with_name("output")
                .help("output file; if missing, result is printed to STDOUT")
                .takes_value(true)
                .short("o")
                .long("output")
                .value_name("FILE")
                .display_order(3),
        )
        .arg(
            clap::Arg::with_name("threads")
                .help("number of threads; '0' will use all available CPUs")
                .takes_value(true)
                .short("t")
                .long("threads")
                .default_value("1")
                .value_name("NUM"),
        )
        .get_matches();

    // calling unwrap is safe here because `input` was `required` by clap
    // and `NA-char` has a default as well as allowed arguments.
    let infname = matches.value_of("input").unwrap().to_string();
    let na_char = matches.value_of("NA-char").unwrap().chars().next().unwrap();
    let output = match matches.value_of("output") {
        None => None,
        Some(fname) => Some(fname.to_string()),
    };
    let threads: usize = matches
        .value_of("threads")
        .unwrap()
        .parse()
        .unwrap_or_else(|err| {
            eprintln!(
                "Error parsing command line arguments: {}. \n\
                 Please provide a valid integer value for the threads argument",
                err
            );
            std::process::exit(1);
        });
    (infname, na_char, output, threads)
}

bitarr.rs -- holds BitArrNA struct

use bitvec::prelude as bv;
use itertools::izip;
use std::fmt;

pub struct BitArrNa {
    pub bits: bv::BitVec<bv::Local, usize>,
    pub not_nas: bv::BitVec<bv::Local, usize>,
}

impl BitArrNa {
    // create new `BitArrNa` with all-zero bits and all-one not_nas
    pub fn new(size: usize) -> BitArrNa {
        let bits = bv::bitvec![0; size];
        let not_nas = bv::bitvec![1; size];

        BitArrNa { bits, not_nas }
    }

    pub fn from_string(string: &str, na_char: char) -> Result<BitArrNa, String> {
        let mut bitarr = BitArrNa::new(string.len());
        for (i, c) in string.chars().enumerate() {
            if c == '0' {
                continue;
            } else if c == '1' {
                bitarr.bits.set(i, true);
            } else if c == na_char {
                bitarr.not_nas.set(i, false);
            } else {
                return Err(format!(
                    "Char at position {} was \'{}\'; expected \'0\', \'1\' or \'{}\'.",
                    i + 1,
                    c,
                    na_char
                ));
            }
        }

        Ok(bitarr)
    }

    pub fn dist<T>(&self, other: &BitArrNa) -> T
    where
        T: num_traits::Num + num_traits::cast::FromPrimitive,
    {
        let mut result: T = T::zero();

        for (bits, not_nas, other_bits, other_not_nas) in izip!(
            self.bits.as_slice(),
            self.not_nas.as_slice(),
            other.bits.as_slice(),
            other.not_nas.as_slice()
        ) {
            let res_bits = bits ^ other_bits;
            let res_not_nas = not_nas & other_not_nas;
            let incr = T::from_u32((res_bits & res_not_nas).count_ones())
                .expect("Error converting distance to requested type");
            result = result + incr;
        }

        result
    }
}

impl fmt::Display for BitArrNa {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "bits:\t\t{}\nnot_nas:\t{}", self.bits, self.not_nas)
    }
}

trimat.rs -- holds TriMat struct

use std::cmp;
use std::io;
use std::ops;

#[derive(Debug)]
pub struct TriMat<T> {
    pub mat: Vec<Vec<T>>,
}

impl<T> TriMat<T> {
    pub fn new(n: usize) -> TriMat<T> {
        let mut mat: Vec<Vec<T>> = Vec::with_capacity(n);
        for i in 0..n {
            mat.push(Vec::with_capacity(n - i));
        }
        TriMat { mat }
    }
}

impl<T> TriMat<T>
where
    T: num_traits::Zero + std::string::ToString + Copy,
{
    pub fn write_symmetric<Buffer: io::Write>(&self, buffer: &mut Buffer) {
        let n = self.mat.len();
        for i in 0..n + 1 {
            let mut line: Vec<T> = Vec::with_capacity(n);
            for j in 0..n + 1 {
                let dist: T;
                if i == j {
                    dist = T::zero();
                } else {
                    let smaller = cmp::min(i, j);
                    let larger = cmp::max(i, j);
                    dist = self[smaller][larger - smaller - 1];
                }
                line.push(dist);
            }
            let line: Vec<String> = line.into_iter().map(|i| i.to_string()).collect();
            writeln!(buffer, "{}", &line.join(","))
                .expect(&format!("Error writing result at i: {}", i));
        }
    }
}

impl<T> ops::Index<usize> for TriMat<T> {
    type Output = Vec<T>;

    fn index(&self, i: usize) -> &Self::Output {
        &self.mat[i]
    }
}

impl<T> ops::IndexMut<usize> for TriMat<T> {
    fn index_mut(&mut self, i: usize) -> &mut Self::Output {
        &mut self.mat[i]
    }
}

cargo.toml

[package]
name = "binary_hamming_dist"
version = "0.1.0"
edition = "2018"

[dependencies]
bitvec = "0.17.4"
itertools = "0.9.0"
rayon = "1.3.0"
indicatif = "0.14.0"
clap = "~2.27.0"
num-traits = "0.2.11"
```
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.