Chap.5 Structs
- 구조체(struct)는 여러 값을 묶어서 어떤 의미를 갖는 데이터 단위를 정의하는 데에 사용합니다.
- 객체지향 언어에 익숙하신 분들은 구조체를 "객체가 갖는 데이터 속성"과 같은 개념으로 이해하셔도 좋습니다.
- 이번 장에선 앞서 배운 튜플과 구조체 간 비교, 구조체 사용법, 구조체의 데이터와 연관된 동작을 표현하는 메소드, 연관함수(associated functions) 정의 방법을 다룹니다.
- 필요한 데이터 형식을 작성할 때 구조체나 열거형(6장에서 배울 예정입니다)을 이용하면, 여러분이 직접 만든 타입에도 러스트의 컴파일 시점 타입 검사 기능을 최대한 활용할 수 있습니다.
5.1 구조체 정의 및 인스턴트화
- 구조체는 3장에서 배운 튜플과 비슷합니다.
- 튜플처럼 구조체의 구성 요소들은 각각 다른 타입이 될 수 있습니다.
- 그리고 여기에 더해서, 구조체는 각각의 구성 요소에 이름을 붙일 수 있습니다.
- 따라서 각 요소가 더 명확한 의미를 갖게 되고, 특정 요소에 접근할 때 순서에 의존할 필요도 사라집니다.
- 결론적으로, 튜플보다 유연하게 사용할 수 있습니다.
구조체를 정의할 땐 struct
키워드와 해당 구조체에 지어줄 이름을 입력하면 됩니다. 이때 구조체 이름은 함께 묶을 데이터의 의미에 맞도록 지어주세요. 이후 중괄호 안에서는 필드(field)라 하는 각 구성 요소의 이름 및 타입을 정의합니다.
struct User {
username: String,
email: String,
sign_in_count: u64,
active: bool,
}
- 정의한 구조체를 사용하려면 해당 구조체 내 각 필드의 값을 정해 인스턴스(instance)를 생성해야 합니다.
- 구조체 정의는 대충 해당 구조체에 무엇이 들어갈지를 정해둔 양식이며, 인스턴스는 거기에 실제 값을 넣은 것이라고 생각하시면 됩니다. 예시로 확인해 보죠
struct User {
username: String,
email: String,
sign_in_count: u64,
active: bool,
}
fn main() {
let user1 = User {
email: String::from("someone@example.com"),
username: String::from("someusername123"),
active: true,
sign_in_count: 1,
};
}
user1.email = String::from("anotheremail@example.com");
fn build_user(email: String, username: String) -> User {
User {
email: email,
username: username,
active: true,
sign_in_count: 1,
}
}
- 구조체 내 특정 값은 점(.) 표기법으로 얻어올 수 있습니다. 사용자의 이메일 주소를 얻어야 한다치면
user1.email
처럼 사용할 수 있죠. - 변경 가능한 인스턴스라면, 같은 방식으로 특정 필드의 값을 변경할 수도 있습니다.
- 가변성은 해당 인스턴스 전체가 지니게 됩니다.(일부 필드만 변경 가능하도록 만들 수는 없음)
- 구조체도 다른 표현식과 마찬가지로 함수 마지막 표현식에서 암묵적으로 새 인스턴스를 생성하고 반환할 수 있습니다.
변수명과 필드명이 같을 때 간단하게 필드 초기화하기
변수명과 구조체 필드명이 같을 땐, 필드 초기화 축약법(field init shorthand)을 사용해서 더 적은 타이핑으로 같은 기능을 구현할 수 있습니다.
fn build_user(email: String, username: String) -> User {
User {
email,
username,
active: true,
sign_in_count: 1,
}
}
기존 인스턴스를 이용해 새 인스턴스를 만들 때 구조체 갱신법 사용하기
기존에 있던 인스턴스에서 대부분의 값을 유지한 채로 몇몇 값만 바꿔 새로운 인스턴스를 생성하게 되는 경우가 간혹 있습니다. 그럴 때 유용한 게 바로 구조체 갱신법(struct update syntax)입니다.
struct User {
username: String,
email: String,
sign_in_count: u64,
active: bool,
}
fn main() {
let user1 = User {
email: String::from("someone@example.com"),
username: String::from("someusername123"),
active: true,
sign_in_count: 1,
};
// 구조체 갱신법 X
let user2 = User {
email: String::from("another@example.com"),
username: String::from("anotherusername567"),
active: user1.active,
sign_in_count: user1.sign_in_count,
};
// 구조체 갱신법 O
let user2 = User {
email: String::from("another@example.com"),
username: String::from("anotherusername567"),
..user1
};
}
필드명이 없는, 타입 구분용 튜플 구조체
- 구조체를 사용 해 튜플과 유사한 형태의 튜플 구조체(tuple structs)를 정의할 수도 있습니다.
- 튜플 구조체는 필드의 이름을 붙이지 않고 필드 타입 만을 정의하며, 구조체 명으로 의미를 갖는 구조체입니다.
- 이는 튜플 전체에 이름을 지어주거나 특정 튜플을 다른 튜플과 구분 짓고 싶은데, 그렇다고 각 필드명을 일일이 정해 일반적인 구조체를 만드는 것은 배보다 배꼽이 더 큰 격이 될 수 있을 때 유용합니다.
튜플 구조체의 정의는 일반적인 구조체처럼 struct
키워드와 구조체 명으로 시작되나, 그 뒤에는 타입들로 이루어진 튜플이 따라옵니다.
fn main() {
struct Color(i32, i32, i32);
struct Point(i32, i32, i32);
let black = Color(0, 0, 0);
let origin = Point(0, 0, 0);
}
black
,origin
이 서로 다른 튜플 구조체의 인스턴스이므로, 타입이 서로 달라진다는 점이 중요합니다. 구조체 내 필드 구성이 같더라도 각각의 구조체는 별도의 타입이기 때문이죠.- 즉,
Color
타입과Point
타입은 둘 다i32
값 3 개로 이루어진 타입이지만,Color
타입을 매개변수로 받는 함수에Point
타입을 인자로 넘겨주는 건 불가능합니다.
필드가 없는 유사 유닛 구조체
- 필드가 아예 없는 구조체를 정의할 수도 있습니다.
- 유닛 타입인
()
과 비슷하게 동작하므로 유사 유닛 구조체(unit-like structs) 라 지칭 - 어떤 타입을 내부 데이터 저장 없이 10장에서 배울 트레잇을 구현하기만 하는 용도로 사용할 때 주로 활용됩니다.
구조체 데이터의 소유권
-
User
구조체 정의에서는 의도적으로&str
문자열 슬라이스 대신 구조체가 소유권을 갖는String
타입을 사용했습니다. - 구조체 인스턴스가 유효한 동안 인스턴스 내의 모든 데이터가 유효하도록 만들기 위해서죠. - 참조자를 이용해 구조체가 소유권을 갖지 않는 데이터도 저장할 수는 있지만, - 이는 10장에서 배울 라이프타임(lifetime)을 활용해야 합니다. - 라이프타임을 사용하면 구조체가 존재하는 동안에 구조체 내 참조자가 가리키는 데이터의 유효함을 보장받을 수 있기 때문이죠. -
만약 라이프타임을 명시하지 않고 참조자를 저장하고자 하면 다음처럼 문제가 발생합니다.
-
위 에러를 해결하여 구조체에 참조자를 저장하는 방법은 10장에서 알아볼 겁니다.
-
지금 당장은
&str
대신String
을 사용함으로써 넘어가도록 하죠.
5.2 구조체를 사용한 예제 프로그램
어떨 때 구조체를 사용하면 좋을지를, 사각형 넓이를 계산하는 프로그램을 작성하면서 익혀보도록 합시다.
아래 예제는 함수와 rect의 연관성 X
fn main() {
let width1 = 30;
let height1 = 50;
println!(
"The area of the rectangle is {} square pixels.",
area(width1, height1)
);
}
fn area(width: u32, height: u32) -> u32 {
width * height
}
튜플로 리팩토링하기
튜플을 사용함으로써 더 짜임새 있는 코드가 됐고, 인자도 단 하나만 넘기면 된다는 점에선 프로그램이 발전했다고 볼 수 있습니다. 하지만 각 요소가 이름을 갖지 않는 튜플의 특성 때문에 값을 인덱스로 접근해야 해서 계산식이 난잡해졌네요.
fn main() {
let rect1 = (30, 50);
println!(
"The area of the rectangle is {} square pixels.",
area(rect1)
);
}
fn area(dimensions: (u32, u32)) -> u32 {
dimensions.0 * dimensions.1
}
구조체로 리팩토링하여 코드에 더 많은 의미를 담기
구조체는 데이터에 이름표를 붙여서 의미를 나타낼 수 있습니다.
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect1 = Rectangle {
width: 30,
height: 50,
};
println!(
"The area of the rectangle is {} square pixels.",
area(&rect1)
);
}
fn area(rectangle: &Rectangle) -> u32 {
rectangle.width * rectangle.height
}
area
함수의 매개변수는 이제rectangle
하나뿐입니다.- 단, 구조체의 소유권을 가져와 버리면
main
함수에서area
함수 호출 이후에rect1
을 더 사용할 수 없으므로,rectangle
매개변수의 타입을 불변 참조자 타입으로 정하여 소유권을 빌려오기만 하도록 만들었습니다. - 불변 참조자 타입이니 함수 시그니처와 호출시에
&
를 작성합니다. area
함수는Rectangle
인스턴스의width
,height
필드에 접근하여area
, 즉 넓이를 계산합니다.- 이제 함수 시그니처만 봐도 의미를 정확히 알 수 있네요.
width
,height
가 서로 연관된 값이라는 것도 알 수 있고, 0
이나1
대신 필드명을 사용해 더 명확하게 구분할 수 있습니다.
트레잇 derive 로 유용한 기능 추가하기
-
println!
매크로에는 여러 출력 형식을 사용할 수 있습니다. -
그리고 기본 형식인
{}
로 지정할 땐Display
라는, 최종 사용자를 위한 출력 형식을 사용하죠. -
여태 사용했던 기본 타입들은
Display
가 기본적으로 구현돼있었습니다.1
같은 기본 타입들을 사용자에게 보여줄 수 있는 형식은 딱 한 가지뿐이니까요. -
하지만 구조체라면 이야기가 달라집니다. 중간중간 쉼표를 사용해야 할 수도 있고, 중괄호도 출력해야 할 수도 있고, 필드 일부를 생략해야 할 수도 있는 등 여러 경우가 있을 수 있습니다.
-
러스트는 이런 애매한 상황에 우리가 원하는 걸 임의로 예상해서 제공하려 들지 않기 때문에, 구조체에는
Display
구현체가 기본 제공되지 않습니다. -
따라서
println!
에서 처리할 수 없죠. -
println!
매크로 호출을println!("rect1 is {:?}", rect1);
으로 바꿔봅시다. -
{}
내에:?
를 추가하는 건println!
에Debug
라는 출력 형식을 사용하고 싶다고 전달하는 것과 같습니다. -
이
Debug
라는 트레잇은 최종 사용자가 아닌, 개발자에게 유용한 방식으로 출력하는, 즉 디버깅할 때 값을 볼 수 있게 해주는 트레잇입니다. -
러스트는 디버깅 정보를 출력하는 기능을 자체적으로 가지고 있습니다.
-
하지만 우리가 만든 구조체에 해당 기능을 적용하려면 명시적인 동의가 필요하므로, 구조체 정의 바로 이전에
#[derive(Debug)]
어노테이션을 작성해주어야 합니다.- Annotation은 프로그램 내에서 주석과 유사하게, 프로그래밍 언어에는 영향을 미치지 않으면서 프로그램/프로그래머에게 유의미한 정보를 제공하는 역할을 한다.
- 컴파일러가 특정 오류를 억제하도록 지시하는 것과 같이 프로그램 코드의 일부가 아닌 프로그램에 관한 데이터를 제공, 코드에 정보를 추가하는 정형화된 방법.
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect1 = Rectangle {
width: 30,
height: 50,
};
println!("rect1 is {:?}", rect1);
}
- 가장 예쁜 출력 형태라 할 수는 없지만, 인스턴스 내 모든 필드 값을 보여주므로 디버깅하는 동안에는 확실히 유용할 겁니다.
- 필드가 더 많은 구조체라면 이보다 더 읽기 편한 형태가 필요할 텐데요.
- 그럴 땐
println!
문자열 내에{:?}
대신{:#?}
를 사용하면 됩니다.
러스트에선 이처럼 derive
어노테이션으로 우리가 만든 타입에 유용한 동작을 추가할 수 있는 트레잇을 여럿 제공합니다. 이들 목록 및 각각의 동작은 부록 C에서 확인할 수 있으니 참고해주세요. 또한, 여러분만의 트레잇을 직접 만들고, 이런 트레잇들의 동작을 원하는 대로 커스터마이징 해서 구현하는 방법은 10장에서 배울 예정입니다.
본론으로 돌아옵시다. 우리가 만든 area
함수는 사각형의 면적만을 계산하며, Rectangle
구조체를 제외한 다른 타입으로는 작동하지 않습니다. 그렇다면 아예 Rectangle
구조체와 더 밀접하게 만드는 편이 낫지 않을까요? 다음에는 area
함수를 Rectangle
타입 내에 메소드(method) 형태로 정의하여 코드를 리팩토링하는 방법을 알아보겠습니다.
5.3 메소드 문법
- 메소드(method) 는 함수와 유사합니다.
fn
키워드와 함수명으로 선언하고, 매개변수와 반환 값을 가지며, 다른 어딘가로부터 호출될 때 실행됩니다.- 하지만 메소드는 함수와 달리 구조체 문맥상에 정의되고(열거형이나 트레잇 객 체 안에 정의되기도 하며, 이는 각각 6장, 17장에서 알아보겠습니다), 첫 번째 매개변수가 항상
self
라는 차이점이 있습니다.self
매개변수는 메소드가 호출된 구조체 인스턴스를 나타냅니다.
메소드 정의
기존의 Rectangle
매개변수를 갖던 area
함수를 Rectangle
구조체에 정의된 area
메소드로 바꿔봅시다.
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn area(&self) -> u32 {
self.width * self.height
}
}
fn main() {
let rect1 = Rectangle {
width: 30,
height: 50,
};
println!(
"The area of the rectangle is {} square pixels.",
rect1.area()
);
}
Rectangle
내에 함수를 정의하기 위해서impl
(구현: implementation) 블록을 만들고area
함수를 옮겨온 후,- 기존에 있던 첫 번째 매개변수를 (이 경우엔 유일한 매개변수네요) 함수 시그니처 및 본문 내에서 찾아
self
로 변경했습니다. - 그리고
main
함수 내에선rect1
을 인수로 넘겨area
함수를 호출하는 대신, 메소드 문법(method syntax) 을 사용해Rectangle
인스턴스의area
메소드를 호출했습니다. - 메소드 문법은 차례대로 인스턴스, 점, 메소드명, 괄호 및 인수로 구성됩니다.
-
area
시그니처부터 살펴보도록 하겠습니다. -
기존의
rectangle: &Rectangle
이&self
로 바뀌었네요. -
Rectangle
을 명시하지 않아도 되는 이유는, 메소드가impl Rectangle
내에 있다는 점을 이용해 러스트가self
의 타입을 알아낼 수 있기 때문입니다. -
또한
self
앞에 기존&Rectangle
처럼&
가 붙은 점을 주목해주세요. -
메소드는 지금처럼
self
를 변경 불가능하게 빌릴 수도 있고, 다른 매개변수처럼 변경 가능하도록 빌릴 수도 있고, 아예 소유권을 가져올 수도 있습니다. -
&self
를 사용해 변경 불가능하게 빌려온 이유는 기존에&Rectangle
을 사용했던 이유와 같습니다. -
우리가 원하는 건 소유권을 가져오는 것도, 데이터를 작성하는 것도 아닌, 데이터를 읽는 것뿐이니까요.
-
만약 메소드에서 호출된 인스턴스를 변경해야 한다면?
&mut self
를 사용하면 됩니다. -
self
로만 작성하여 인스턴스의 소유권을 가져오도록 만드는 일은 거의 없습니다.- 그나마
self
를 다른 무언가로 변형시키고, 그 이후에는 원본 인스턴스 사용을 막고자 할 때나 종종 볼 수 있죠.
- 그나마
함수 대신 메소드를 이용했을 때의 주요 이점은 메소드 시그니처 내에서 self
의 타입을 반복해서 입력하지 않아도 된다는 것뿐만이 아닙니다.
코드를 더 조직적으로 만들 수 있죠. 우리가 라이브러리를 제공하게 된다고 가정해봅시다. 향후 우리가 제공한 라이브러리를 사용할 사람들이 Rectangle
에 관련된 코드를 라이브러리 곳곳에서 찾아내야 하는 것과, impl
블록 내에 모두 모아둔 것 중에 어떤 것이 나을까요? 답은 명확합니다.