Rust 프로그래밍 13장 - 제네릭 & 공변성
회사에서 주로 코틀린 70%, 러스트 20% , 자바스크립트, DevOps 위한 스크립트 10%등으로 작업을 하고 있습니다. (언어/ 프레임워크 선택권이 나한테 있음) 러스트가 반드시 필요했냐? 음 그건 아닌거 같습니다. 다만 몇몇 가지 이유가 있긴 합니다. (굳이 논쟁이 붙을 여러 이유를 이런 튜토리얼에서 하고 싶진 않습니다. 근데 이유중 하나로 ChatGpt가 들어갑니다. ㅎㅎ)
1. 제네릭이란?
제네릭이란 코드에서 타입을 매개변수화하여 다양한 타입에 대해 재사용 가능하게 만드는 기능입니다.
Rust에서는 함수, 구조체, 열거형, 트레이트 등에서 제네릭을 사용할 수 있습니다.
제네릭을 사용하면 코드 중복을 줄이고, 타입 안전성을 유지하면서 다양한 타입을 처리할 수 있습니다.
fn print_item<T>(item: T) {
println!("{:?}", item);
}
fn main() {
print_item(5); // 정수
print_item("Hello"); // 문자열
}위의 예시에서 T는 타입 매개변수로, print_item 함수는 어떤 타입이든 처리할 수 있습니다.
2. 제네릭 타입 매개변수
제네릭 타입 매개변수는 타입을 함수나 구조체의 인자로 사용할 수 있게 해줍니다. <T>는 타입 매개변수로 어떤 타입도 대체할 수 있는 자리표시자입니다.
fn get_first<T>(vec: Vec<T>) -> T {
vec[0] // T 타입의 첫 번째 요소 반환
}
fn main() { l
let int_vec = vec![1, 2, 3];
let string_vec = vec!["a", "b", "c"];
let first_int = get_first(int_vec);
let first_string = get_first(string_vec);
println!("First integer: {}", first_int);
println!("First string: {}", first_string);
}3. 제네릭 구조체
구조체에서도 제네릭을 사용할 수 있습니다. 제네릭을 사용하면 다양한 타입의 데이터를 하나의 구조체로 처리할 수 있습니다.
struct Point<T> {
x: T,
y: T,
}
fn main() {
let int_point = Point { x: 5, y: 10 };
let float_point = Point { x: 1.1, y: 2.2 };
println!("Point: ({}, {})", int_point.x, int_point.y);
println!("Point: ({}, {})", float_point.x, float_point.y);
}4. 제네릭 함수에서 여러 타입 사용하기
여러 타입 매개변수를 사용할 수 있습니다.
함수의 매개변수로 여러 제네릭 타입을 지정하여 다양한 타입을 처리할 수 있습니다.
fn compare<T, U>(a: T, b: U) {
println!("{:?} vs {:?}", a, b);
}
fn main() {
compare(10, "Hello");
compare(1.5, 2.7);
}5. 제네릭과 트레이트 제한 (Trait Bounds)
제네릭 타입 매개변수에 대해 트레이트를 제한할 수 있습니다.
이를 통해 해당 타입이 특정 트레이트를 구현했는지 확인할 수 있습니다.
트레이트 제한은 제네릭을 사용할 때 자주 필요합니다.
use std::fmt::Debug;
fn print_debug<T: Debug>(item: T) {
println!("{:?}", item);
}
fn main() {
let int_value = 42;
let str_value = "Hello";
print_debug(int_value); // Debug 구현
print_debug(str_value); // Debug 구현
}위 코드에서 T: Debug는 T 타입이 Debug 트레이트를 구현해야만 print_debug 함수가 호출될 수 있음을 의미합니다.
5-1 여러 트레이트 제한을 where 절을 사용하여 명시하기
use std::fmt::Debug;
use std::cmp::Ord;
fn print_and_compare<T, U>(a: T, b: U)
where T: Debug + Ord, U: Debug { // where 을 통해 제약 걸기
println!("{:?} vs {:?}", a, b); // a,b는 둘다 Debug 제약이 걸림
// T와 U를 비교하려면 T가 Ord 트레이트를 구현해야 한다.
if a > b {
println!("{:?} is greater", a);
} else {
println!("{:?} is less than or equal to {:?}", a, b);
}
}
fn main() {
let x = 10;
let y = 5;
let z = "Hello"; // x와 y는 Ord + Debug를 만족하므로 비교할 수 있다.
print_and_compare(x, y); // 10과 5는 정수이므로 Ord + Debug가 가능
print_and_compare(z, y); // Error: 'z'는 Ord를 만족하지 않음
}where절은 여러 트레이트 제한을 하나의 구문으로 분리하여 정의할 수 있게 해줍니다.T: Debug + Ord는 T 타입이Debug와Ord트레이트를 구현해야 한다는 의미입니다.Ord는 크기 비교가 가능하도록 하는 트레이트입니다.U: Debug는 U 타입이Debug트레이트만 구현하면 된다는 의미입니다.where절을 사용하면 제네릭 함수 시그니처를 더 간결하게 만들 수 있고, 긴 트레이트 제한을 여러 줄에 나눠서 명시할 수 있습니다.
6. 구체적인 타입과 제네릭 타입의 혼합
제네릭과 구체적인 타입을 혼합하여 사용할 수 있습니다. 구체적인 타입은 제네릭을 사용할 때 명시적으로 지정할 수 있습니다.
use std::fmt::Debug;
#[derive(Debug)]
struct Employee<T, U> {
name: String,
age: T,
job: U,
}
impl<T, U> Employee<T, U> {
// 생성자 메서드
fn new(name: String, age: T, job: U) -> Self {
Employee { name, age, job }
}
// 나이를 반환하는 메서드
fn get_age(&self) -> &T {
&self.age
}
// 직무를 반환하는 메서드
fn get_job(&self) -> &U {
&self.job
}
}
fn main() {
// 구체적인 타입을 지정하여 Employee 생성
let dev_employee = Employee::new("Alice".to_string(), 30, "Developer".to_string());
let designer_employee = Employee::new("Bob".to_string(), 25, "Designer".to_string());
// 디버깅 출력을 위해 Debug 트레이트 사용
println!("Employee 1: {:?}", dev_employee);
println!("Employee 2: {:?}", designer_employee);
// 나이를 가져오기
println!("Employee 1 Age: {}", dev_employee.get_age());
println!("Employee 2 Age: {}", designer_employee.get_age());
// 직무 가져오기
println!("Employee 1 Job: {}", dev_employee.get_job());
println!("Employee 2 Job: {}", designer_employee.get_job());
// 직무를 바꾸는 예
let new_dev_employee = Employee::new("Charlie".to_string(), 28, "Senior Developer".to_string());
println!("New Developer: {:?}", new_dev_employee);
}
부록) 공변성 (Covariance) 과 반변성(Contravariance)
Rust는 자바와 같은 객체 지향 언어에서의 *공변성 (Covariance)이나 반변성 (Contravariance)*과는 약간 다른 방식으로 타입 변환을 다룹니다. Rust의 타입 시스템에서 공변성과 반변성은 주로 참조와 라이프타임에서 중요한 개념으로 다뤄지며, 자바에서처럼 타입 계층을 따라 직접적인 변환을 지원하지는 않습니다.
1. 자바에서의 공변성 (Covariance)
공변성만 이해해 봅시다. 공변성 이해하면 나머지는 그냥 이해한거나 마찬가지.
1) ----------------- // 공변성 : 제네릭에서 상위 타입으로 캐스팅 ArrayList<Object> Covariance = new ArrayList<Integer>(); // 반공변성 : 제네릭에서 하위타입으로 캐스팅 ArrayList<Integer> Contravariance = new ArrayList<Object>(); ------------------ 둘 다 자바에선 안됩니다. 즉 공변성,반공변성이 ArrayList에서는 지원 안된다고 보면 됩니다. 아래 예를 보면 이유를 알 수 있습니다. public class User { } public class Man extends User {} public class Woman extends User {} 일때 User user = new Woman(); // 당연히 되지만 List<User> listUser= new ArrayList<Woman>(); // 이거는 안됩니다. 만약 List<User> users = new ArrayList<Woman>(); // 이게 된다면 Man man = new Man(); users.add(man); // 남자도 User니까 넣었는데 Women women = users.get(0); // 여자로 작업되면 망하게 되니까요 2) 컬렉션 자체의 부모,자식간에는 캐스팅이 됩니다. 다만 내부타입은 같아야 합니다. Collection<Integer> parent = new ArrayList<>(); ArrayList<Integer> child = new ArrayList<>(); parent = child; // 다형성 (업캐스팅) ArrayList<Object> parent = new ArrayList<>(); ArrayList<Integer> child = new ArrayList<>(); parent = child; // ! 업캐스팅 불가능 child = parent; // ! 다운캐스팅 불가능 즉 이건 안됩니다. 3) 근데 가끔 이런 식으로 사용하고 싶을 때가 생깁니다. 이때 우리는 공변성질을 넣어 주게 됩니다. public static void print(List<Object> arr) { for (Object e : arr) { System.out.println(e); } } public static void main(String[] args) { List<Integer> integers = Arrays.asList(1, 2, 3); print(integers); // ! Error } 즉 그냥 어떤 타입의 리스트건 넘어오면 출력해주고 싶습니다. 사실 그게 Integer건 String이건 상관없잖아요 그저 출력하자는건데... 자 이제 이런 문제를 해결 해 봅시다. <? extends U> 네 위의 표현식이 이 문제를 해결해 줍니다. public MyArrayList(Collection<? extends T> in) { for(T elem : in) { elem.shout() } } 즉 이런식으로 T 타입의 자식타입들을 모두 받아서 사용 할 수 있게 됩니다. 참고로 코틀린에서는 fun printAnimals(animals: List<out Animal>) { for (animal in animals) { println(animal) } } 이렇게 out으로 표현합니다. 즉 타입의 "출력만 가능", "읽기 전용"으로 사용만 한다는 것으로 더 직관적입니다. 이게 제일 중요한 핵심입니다. 해당 타입에 대해서 어떤 것의 입력이나 쓰기로 사용된다면 큰 문제가 생기기 때문입니다. 잘 보면 너무 당연 할 겁니다. 위에 animals에 사람도 넣고, 원숭이도 넣으면 안되겠지요? 그저 읽어서 animals이라면 공동으로 가지고 있는 어떠한 행위만 시키거나 읽을 수 있는게 당연합니다. 마지막으로 반공변성은 반대입니다. 자바에서 반공변성은 ? super T를 코틀린에서 반공변성은 in 키워드를 사용하여 표현합니다. in을 사용한 제네릭 타입은 입력 전용으로만 사용될 수 있으며, 해당 타입을 하위 타입으로 제한합니다. 즉 매개변수로 받은 놈한테 입력 할 수 있다는 의미. 4) 자바에서 **Use-site variance**는 타입 매개변수의 제약을 사용 지점에서 정의하는 방식을 말합니다. 즉, 제네릭 타입을 제네릭 클래스를 만들때 사용하는 것이 아니라 사용하는 지점에서 공변성(? extends T)이나 반공변성(? super T)을 명시하는 방식입니다. 이는 제네릭 타입을 사용하는 곳에서만 변성을 정의할 수 있다는 의미입니다. 반면, **러스트(Rust)**에서는 Use-site variance 과는 다르게 제네릭 타입을 트레이트 바운드와 불변 참조, 가변 참조를 이용해 변성을 제어합니다. 러스트는 타입 변성을 명시적으로 지정하지 않고, 대신 참조의 타입에 의해 자연스럽게 변성을 결정합니다. 즉, 제네릭을 사용한 타입에 대해 변성을 내부적으로 처리하며, 외부에서 타입을 사용하는 방식으로는 변성에 대한 제어가 제한적입니다. C#은 declaration-site variance 를 사용합니다.
2. 러스트의 공변성과 반변성
위의 예제에서 코틀린에서 공변성은 in 으로 나타낸다고 했습니다. 즉 출력만 가능, 읽기 전용이 핵심입니다.
Rust는 제네릭에 대해 공변성과 반변성을 명시적으로 다루지는 않지만, **참조 타입 (&T)**에 대해서는 자바의 공변성과 비슷한 방식으로 타입을 다루기 위해 참조의 특성을 활용합니다.
1. 공변성 (Covariance)
원시 포인터(const T): 참조와 유사하게, 하위 유형(const B)에 대한 원시 포인터는 상위 유형(*const A)에 대한 원시 포인터의 하위 유형입니다. 이는 둘 다 동일한 메모리 영역을 가리키지만, 하위 유형 포인터는 가리키는 데이터에 대한 더 구체적인 정보를 가지고 있기 때문입니다.
Box (스마트 포인터): Box<B>는 B가 A의 하위 유형인 경우 Box<A>의 하위 유형입니다. 이는 Box가 참조와 유사하게 작동하고 내부 유형의 하위 유형 관계가 적용되기 때문입니다.
슬라이스 ([T]): 요소 유형의 수명이 동일한 경우, 서브타입([B])의 슬라이스는 슈퍼타입([A])의 슬라이스의 서브타입입니다. 이는 슬라이스가 기본적으로 첫 번째 요소와 길이에 대한 포인터이며, 서브타이핑은 이러한 속성을 유지하기 때문입니다.
배열 ([T; n]): 슬라이스와 유사하게, 서브타입 배열 ([B; n])은 동일한 수명과 요소 수가 주어졌을 때 슈퍼타입 배열 ([A; n])의 서브타입입니다.
함수 반환 유형(T): 함수가 T 유형을 반환하고 B가 A의 하위 유형일 때, 함수에서 B를 반환하는 것은 반환 A의 하위 유형으로 간주됩니다. 이를 통해 함수 설계에 유연성을 부여할 수 있습니다.
2. 자바와의 차이점
Rust에서는 자바의 제네릭 상한 제한 (? extends T), 하한 제한 (? super T) 같은 명시적인 제약 없이 참조를 통해 이러한 특성을 다룹니다. 이는 Rust의 타입 시스템이 소유권과 불변성(immutability), 변경 가능성(mutability)을 중심으로 설계되었기 때문입니다.
즉, 공변성은 **불변 참조 (&T)**에서, 반변성은 **변경 가능한 참조 (&mut T)**에서 자연스럽게 발생합니다.
언어 | 변성 처리 방식 | 주요 특징 |
|---|---|---|
자바 (Java) | 사용 지점 변성 (Use-site variance) | - |
C# | 선언 사이트 변성 (Declaration-site variance) | - |
스칼라 (Scala) | 선언 사이트 변성 (Declaration-site variance) | - |
러스트 (Rust) | 참조 기반 변성 | - **불변 참조 ( |
코딩 테스트 문제: Generic Recursive Permutations Generator
설명
주어진 아이템들을 모두 담고 있는 슬라이스를 입력받아, 그 아이템들의 모든 순열(permutation)을 생성하는 제네릭 재귀 함수를 작성하세요.
함수는 다음과 같은 시그니처를 가져야 합니다:
fn permutations<T: Clone>(items: &[T]) -> Vec<Vec<T>>
요구 사항
제네릭: 함수는
T: Clone제약을 가지며, 임의의 타입T에 대해 동작해야 합니다.재귀: 함수 로직은 반드시 재귀(recursion)를 사용하여 순열을 생성해야 합니다.
출력: 입력 슬라이스의 모든 가능한 순열을
Vec<Vec<T>>형식으로 반환합니다.순서: 반환되는 순열의 순서는 상관없습니다.
예외: 빈 슬라이스에 대해서는 빈 하나의 순열(즉,
[vec![]])을 반환합니다.
예시
입력이 [1, 2, 3]인 경우, 가능한 출력은 아래와 같습니다 (출력 순서는 달라도 무방함):
[
vec![1, 2, 3],
vec![1, 3, 2],
vec![2, 1, 3],
vec![2, 3, 1],
vec![3, 1, 2],
vec![3, 2, 1]
]
힌트
입력 슬라이스에서 각 원소를 선택하여, 선택하지 않은 나머지 원소들로 재귀 호출을 수행하면 순열을 만들 수 있습니다.
베이스 케이스로 입력 슬라이스의 길이가 0 또는 1인 경우를 고려하세요.
각 재귀 호출에서 선택한 원소와 재귀 호출 결과를 결합하여 새로운 순열을 생성합니다.
추가 예제 코드 (테스트 용도)
fn main() {
let input = vec![1, 2, 3];
let result = permutations(&input);
for perm in result {
println!("{:?}", perm);
}
}
// 여기에 permutations 함수 구현을 작성하세요.
fn permutations<T: Clone>(items: &[T]) -> Vec<Vec<T>> {
// 베이스 케이스: 입력 슬라이스가 비어있거나 한 개의 요소만 있을 때
if items.len() <= 1 {
return vec![items.to_vec()];
}
let mut result = Vec::new();
// 각 원소를 고정하고 나머지에 대해 재귀 호출
for (i, item) in items.iter().enumerate() {
// 현재 원소를 제외한 나머지 원소들로 새로운 벡터를 생성
let mut remaining = items.to_vec();
remaining.remove(i);
// 재귀 호출을 통해 나머지 순열들을 구함
for mut perm in permutations(&remaining) {
// 현재 선택된 원소를 앞에 추가
let mut new_perm = vec![item.clone()];
new_perm.append(&mut perm);
result.push(new_perm);
}
}
result
}
이 문제는 Rust에서 제네릭 타입과 재귀를 활용한 문제 해결 능력을 평가하기에 적합한 문제입니다. 문제를 해결할 때, Rust의 소유권, 빌림(borrowing) 및 제네릭 문법에 주의하면서 구현해 보세요.
답안 코드
fn permutations<T: Clone>(items: &[T]) -> Vec<Vec<T>> {
// 베이스 케이스: items의 길이가 0 또는 1일 경우, 해당 슬라이스를 벡터로 변환하여 반환
if items.len() <= 1 {
return vec![items.to_vec()];
}
let mut result = Vec::new();
// 각 원소를 선택하여 고정한 뒤, 나머지 원소들에 대해 재귀적으로 순열 생성
for (i, item) in items.iter().enumerate() {
// 현재 원소를 제외한 나머지 원소들을 담은 새로운 벡터 생성
let mut remaining = items.to_vec();
remaining.remove(i);
// 재귀 호출을 통해 남은 원소들의 순열 생성
for mut perm in permutations(&remaining) {
// 현재 선택된 원소를 순열의 맨 앞에 추가
let mut new_perm = vec![item.clone()];
new_perm.append(&mut perm);
result.push(new_perm);
}
}
result
}
fn main() {
let input = vec![1, 2, 3];
let result = permutations(&input);
// 결과를 출력
for perm in result {
println!("{:?}", perm);
}
}
코드 설명
기본 아이디어:
주어진 슬라이스의 각 요소를 고정(fix)하고, 나머지 요소들에 대해 재귀적으로 순열을 생성합니다. 고정된 요소와 재귀 호출의 결과를 결합하여 새로운 순열을 구성합니다.베이스 케이스:
슬라이스의 길이가 0 또는 1이면, 이미 순열이 하나뿐이므로 그대로 슬라이스를 벡터로 변환하여 반환합니다.재귀 단계:
for (i, item) in items.iter().enumerate()를 이용해 각 요소에 대해 반복합니다.현재 요소
item을 제외한 나머지 원소들은remaining벡터에 저장합니다. 이때remove(i)를 사용해 현재 인덱스의 원소를 제거합니다.permutations(&remaining)를 재귀적으로 호출하여 남은 순열들을 구합니다.반환된 각 순열에 대해 현재 고정된 요소
item을 앞에 추가하여 최종 순열을 구성한 후 결과 벡터에 추가합니다.
메인 함수:
테스트용으로 입력 벡터[1, 2, 3]에 대해 순열을 생성하고, 각 순열을 출력합니다.
이 방식은 제네릭과 재귀를 효과적으로 활용하여 다양한 타입의 데이터를 처리할 수 있는 순열 생성 함수의 구현 예시입니다
다음 차례)
- 멀티쓰레드와 동기화객체
- select, channel 알아보기
- Tokio를 통한 비동기 예제 보기
- Custome Future 및 Runtime 만들어 보기