Prerequisites: Rust version 1.80.0
이 포스트는 TDD를 실제로 적용한 사례를 소개하기 위한 것으로, Rust의 문법이나 관습에 대한 설명은 대부분 생략한다.
에러를 발생시켜야 할 때는 일반적으로 프로그램이 예기치 못하게 종료되는 것을 방지하기 위해
Result
를 return 하는 방법이 권장되지만, 여기서는 편의상 panic!()
을 사용했다.
요구사항
- Matrix의 원소는 Integer와 float type을 모두 지원해야 한다.
- Matrix의 덧셈, 뺄셈, 곱셈, scalar 곱, transpose를 지원한다.
- Matrix를 쉽게 생성할 수 있는 macro를 지원한다.
- Matrix의 덧셈 및 뺄셈 연산 시, 두 matrix의 행과 열의 수가 동일해야 한다.
- Matrix A와 matrix B를 곱하는 A * B 연산 시, A의 열 수와 B의 행 수가 동일해야 한다.
시작하기
시작하기 앞서, 이 예제는 Rust version 1.80.0에서 작성하고 테스트했다.
버전이 낮을 경우 컴파일되지 않을 수도 있다. (Rust의 버전은 rustc -V
명령어로 확인할 수 있다.)
언제나처럼, cargo
로 프로젝트를 생성한다. 라이브러리이므로 --lib
을 붙여 main.rs
가 생성되지 않도록 한다.
cargo new rustrix --lib
src
아래 matrix
디렉토리를 생성하고, matrix/mod.rs
파일을 생성한다. 그러면 프로젝트 구조는 아래와 같아야 한다.
src/
lib.rs
matrix/
mod.rs
target/
.gitignore
Cargo.lock
Cargo.toml
matrix/mod.rs
에는 struct Matrix
를 정의하고, Matrix
를 생성하기 위한 from
함수를 만들었다.
또, Matrix의 행 수를 알려주는 rows()
method와 열 수를 알려주는 cols()
method를 추가했다.
(Matrix
의 0번 field인 2D vector가 private field이므로 생성자 역할을 하는 함수가 필요하다.
Rust에서는 관습적으로 new()
또는 from()
이라는 이름의 associated function을 통해 struct나 enum을 생성하는데,
여기서는 값이 비어있는 struct를 생성하는 것이 아니라 2d vector를 받아 Matrix를 생성하는 것이기 때문에 from()
을 사용했다.)
/* matrix/mod.rs */
#[derive(Debug, PartialEq, Clone)]
pub struct Matrix<T>(Vec<Vec<T>>);
impl<T> From<Vec<Vec<T>>> for Matrix<T> {
fn from(v: Vec<Vec<T>>) -> Self {
// Validates matrix shape.
if v.len() == 0 || v[0].len() == 0 {
panic!("Invalid matrix shape.");
}
let cols = v[0].len();
for v in v.iter() {
if v.len() != cols {
panic!("Invalid matrix shape.");
}
}
Matrix(v)
}
}
impl<T> Matrix<T> {
pub fn rows(&self) -> usize {
self.0.len()
}
pub fn cols(&self) -> usize {
self.0[0].len()
}
}
lib.rs
은 기존 모든 내용을 지우고 mod matrix
를 추가한다.
/* lib.rs */
mod matrix;
// Users of the library can now use all items in `mod matrix`
// declared with `pub` keyword. (And exported macros, either.)
pub use matrix::*;
매크로 만들기
개발 중에 혹은 사용시에 Matrix를 쉽게 생성할 수 있도록 가장 먼저 매크로를 만든다. 만들 매크로는 두 종류가 있다.
mx!(r, c; v)
: r개의 행과 c개의 열을 가진 Matrix를 생성하고, 모든 원소를를 v로 초기화한다.mx!(r, c)
: r개의 행과 c개의 열을 가진 Matrix를 생성하고, 모든 원소를 0으로 초기화한다.
mx![a, b, c; d, e, f]
:[[a, b, c], [d, e, f]]
와 같은 Matrix를 생성한다.
let m1 = mx!(2, 3; 1);
// m1 looks like below
// 1 1 1
// 1 1 1
let m2 = mx![
1, 2, 3;
4, 5, 6;
];
// m2 looks like below
// 1 2 3
// 4 5 6
매크로를 구현하기 전에, 빈 macro_rules!
를 선언하고 테스트를 만든다.
(Macro를 아직 작성하지 않았기 때문에 test code에는 빨간 줄이 뜬다.)
실제 개발할 때에는 한 번에 모든 테스트를 작성하지 않고 하나씩 작성하고 하나씩 구현하는 식으로 했다.
/* matrix/mod.rs */
#[macro_export]
macro_rules! mx {
() => {};
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn macro_1_1() {
assert_eq!(
mx!(2, 3; 1),
Matrix(vec![vec![1, 1, 1], vec![1, 1, 1]])
);
}
#[test]
fn macro_1_2() {
assert_eq!(
mx!(3, 2),
Matrix(vec![vec![0, 0], vec![0, 0], vec![0, 0]])
);
}
#[test]
fn macro_2() {
assert_eq!(
mx![
1, 2, 3;
4, 5, 6
],
Matrix(vec![
vec![1, 2, 3],
vec![4, 5, 6],
])
);
}
}
macro_rules!
를 작성하고 테스트를 실행해서 매크로가 올바르게 동작하는지 확인한다.
/* matrix/mod.rs */
#[macro_export]
macro_rules! mx {
($r: expr, $c: expr$(; $v: expr)?) => {
Matrix::from(vec![vec![0$(+$v)?; $c]; $r])
};
[$($($v: expr),+);+$(;)?] => {
Matrix::from(vec![$(vec![$($v,)+]),+])
};
}
cargo test
매크로를 제대로 구현했다면 세 개의 테스트가 모두 통과해야 한다.
구현하기
이제 본격적인 요구사항 구현을 할 차례이다.
Matrix
의 연산을 구현하기 위해 matrix/ops.rs
파일을 생성한다.
/* matrix/mod.rs */
mod ops;
덧셈 및 뺄셈
요구사항 확인
Matrix의 덧셈, 뺄셈의 요구사항은 간단하다.
- 두 Matrix의 행과 열의 수가 동일해야 한다. (그렇지 않으면 panic해야 한다.)
- 연산 결과로 올바른 값이 나와야 한다.
테스트 작성
올바르게 작동하지 않는 add method를 추가한다. 그리고 각각의 요구사항에 대한 테스트를 작성한다.
/* matrix/ops.rs */
use std::ops::Add;
use super::Matrix;
impl<T> Add for Matrix<T> {
type Output = Self;
fn add(self, rhs: Self) -> Self::Output {
self // Temporary return value to make it compile
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
#[should_panic]
fn add_panic() {
let _ = mx!(1, 2) + mx!(2, 2);
}
#[test]
fn add() {
assert_eq!(
mx![1, 1, 1; 2, 2, 2] + mx!(2, 3; 1),
mx![2, 2, 2; 3, 3, 3]
);
}
}
cargo test
로 테스트 실행을 하면 matrix::ops::test::add
와 matrix::ops::test::add_panic
은 fail할 것이다.
구현하기
먼저 matrix::ops::test::add_panic
이 pass하도록 두 Matrix의 행과 열 수를 검사하는 코드를 추가한다.
/* matrix/ops.rs */
impl<T> Add for Matrix<T> {
type Output = Self;
fn add(self, rhs: Self) -> Self::Output {
if self.rows() != rhs.rows() || self.cols() != rhs.cols() {
panic!("Shapes of lhs and rhs are different.");
}
self
}
}
cargo test
로 테스트를 실행해 matrix::ops::test::add_panic
이 pass하는지 확인한다.
이제 연산의 결과가 올바르게 나오도록 add()
method를 수정한다.
/* matrix/ops.rs */
impl<T> Add for Matrix<T>
where
T: Copy + Add<Output = T>,
{
type Output = Self;
fn add(self, rhs: Self) -> Self::Output {
if self.rows() != rhs.rows() || self.cols() != rhs.cols() {
panic!("Shapes of lhs and rhs are different.");
}
let mut m = self.clone();
for r in 0..m.rows() {
for c in 0..m.cols() {
m.0[r][c] = m.0[r][c] + rhs.0[r][c];
}
}
m
}
}
cargo test
를 실행하면 matrix::ops::test::add
와 matrix::ops::test::add_panic
이 모두 pass해야 한다.
Matrix의 뺄셈도 같은 방식으로 구현한다. (이후의 다른 연산도 모두 같은 순서로 구현했다.)
Add
와 Sub
를 구현하면서 generic type T
가 만족시켜야 하는 trait이 늘어났으니, from()
에도 반영한다.
/* matrix/mod.rs */
use std::ops::{Add, Sub};
impl<T> From<Vec<Vec<T>>> for Matrix<T>
where
T: Copy + Add<Output = T> + Sub<Output = T>,
{
fn from(v: Vec<Vec<T>>) -> Self {
// ...
}
}
Scalar 곱, transpose
모두 위의 Add
구현 예제와 같은 방법으로 구현했기 때문에, 여기부터는 구체적인 설명은 생략한다.
테스트 작성
/* matrix/ops.rs */
impl<T> Matrix<T> {
pub fn scalar_mul(&self, s: T) -> Self {
unimplemented!();
}
pub fn transpose(&self) -> Self {
unimplemented!();
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn scalar_mul() {
assert_eq!(
mx!(2, 3; 1).scalar_mul(3),
mx!(2, 3; 3)
);
}
#[test]
fn transpose() {
assert_eq!(
mx![
1, 2, 3;
4, 5, 6;
].transpose(),
mx![
1, 4;
2, 5;
3, 6;
]
);
}
}
구현하기
/* matrix/ops.rs */
impl<T> Matrix<T>
where
T: Copy + Mul<Output = T>
{
pub fn scalar_mul(&self, s: T) -> Self {
let v = self.0.iter().map(|r| {
r.iter().map(|&v| v * s).collect::<Vec<_>>()
}).collect::<Vec<_>>();
Matrix(v)
}
}
impl<T> Matrix<T>
where
T: Clone,
{
pub fn transpose(&self) -> Self {
let v = (0..self.cols())
.map(|c| {
(0..self.rows())
.map(|r| self.0[r][c].clone())
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
Matrix(v)
}
}
/* matrix/mod.rs */
use std::ops::{Add, Mul, Sub};
impl<T> From<Vec<Vec<T>>> for Matrix<T>
where
T: Copy + Add<Output = T> + Sub<Output = T> + Mul<Output = T>,
{
fn from(v: Vec<Vec<T>>) -> Self {
// ...
}
}
곱셈 (내적)
요구사항 확인
- Matrix A와 Matrix B를 곱하는 A * B 연산 시, A의 열 수와 B의 행 수가 동일해야 한다. (그렇지 않을 경우 panic해야 한다.)
- 연산 결과로 올바른 값이 나와야 한다.
테스트 작성
/* matrix/ops.rs */
impl<T> Mul for Matrix<T>
where
T: Copy + Add<Output = T> + Mul<Output = T>,
{
type Output = Self;
fn mul(self, rhs: Self) -> Self::Output {
self
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
#[should_panic]
fn mul_panic() {
let _ = mx!(2, 2; 1) * mx!(3, 2; 1);
}
#[test]
fn mul() {
assert_eq!(
mx!(2, 3; 1) * mx! (3, 1; 1),
mx![3; 3]
);
}
}
구현
/* matrix/ops.rs */
impl<T> Mul for Matrix<T>
where
T: Copy + Add<Output = T> + Mul<Output = T>,
{
type Output = Self;
fn mul(self, rhs: Self) -> Self::Output {
if self.cols() != rhs.rows() {
panic!(
"lhs with {} columns cannot be muptiplied with rhs with {} rows.",
self.cols(),
rhs.rows()
);
}
let v = (0..self.rows())
.map(|r| {
(0..rhs.cols())
.map(|c| {
(0..self.cols())
.map(|i| self.0[r][i] * rhs.0[i][c])
.reduce(|acc, v| acc + v)
.expect("`self.cols()` is expected to be greater than 0.")
})
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
Matrix(v)
}
}
생략된 것
Matrix 생성 시 panic 테스트
Matrix를 생성할 때 row의 길이 또는 column의 길이가 일정하지 않거나, 빈 vector가 주어지면 panic해야 한다. 구현은 했지만 그에 대한 테스트는 작성은 생략했다.
Generic Type에 대한 테스트
struct Matrix
는 trait을 만족하는 generic type에 대해 모두 생성할 수 있지만,
테스트는 모두 i32
type에 대해서만 작성했다.
f64
등 여러 type에 대해 테스트를 작성하면 더 안정적인 작동을 보장할 수 있을 것이다.