프로퍼티는 클래스, 구조체 또는 열거형 등에 관련된 값을 뜻하고 메서드는 특정 타입에 관련된 함수를 뜻한다.
프로퍼티는 크게 다음과 같이 3가지로 나눌 수 있다.
저장 프로퍼티는 인스턴스의 변수 또는 상수를 의미한다. 구조체와 클래스에서만 사용할 수 있다.
연산 프로퍼티는 값을 저장한 것이 아니라 특정 연산을 실행한 결괏값을 의미한다. 클래스, 구조체, 열거형에 쓰일 수 있다.
이 두개의 프로퍼티는 특정 타입의 인스턴스에 사용되는 것을 뜻하지만 특정 타입에 사용되는 프로퍼티도 존재하는데 이를 타입 프로퍼티라고 한다.
우리가 실제로 무언가의 프로그램을 짤 때 사용하던 인스턴스 변수는 저장 프로퍼티, 클래스 변수는 타입 프로퍼티로 말할 수 있다.
구조체 또는 클래스의 인스턴스와 연관된 값을 저장하는 가장 단순한 개념의 프로퍼티이다.
var 키워드를 사용하면 변수 저장 프로퍼티, let 키워드를 사용하면 상수 저장 프로퍼티가 된다.
저장 프로퍼티의 선언과 인스턴스 초기화 방법은 다음과 같다.
struct CoordinatePoint {
var x: Int
var y: Int
}
let myPoint: CoordinatePoint = CoordinatePoint(x: 2, y: 4)
class Position {
var point: CoordinatePoint
let name: String
}
만약, 이렇게 구조체와 클래스를 정의한다면 어떻게 될까?
구조체에는 기본적으로 저장 프로퍼티를 매개변수로 갖는 이니셜라이저를 제공하지만 클래스는 그렇지 않기 때문에 에러를 내뱉게 된다.
Class 'Position' has no initializers
따라서 완전한 코드는 다음과 같다.
struct CoordinatePoint {
var x: Int
var y: Int
}
let myPoint: CoordinatePoint = CoordinatePoint(x: 2, y: 4)
class Position {
var point: CoordinatePoint // 변수 저장 프로퍼티
let name: String // 상수 저장 프로퍼티
// 프로퍼티 기본값을 설정해주지 않았기 때문에 이니셜라이저를 따로 정의
init(name: String, currentPoint: CoordinatePoint) {
self.name = name
self.point = currentPoint
}
}
// 사용자 정의 이니셜라이저 호출
let myPosition: Position = Position(name: "silverCastle", currentPoint: myPoint)
하지만, 클래스의 저장 프로퍼티에 미리 초기값을 설정해준다면 따로 사용자 정의 이니셜라이저를 구현할 필요가 없음을 알아두자.
인스턴스를 생성할 때 이니셜라이저를 통해 초기값을 보내야 하는 이유는 과연 무엇일까?
그것은 바로, 프로퍼티가 옵셔널이 아닌 값으로 선언되어 있기 때문에 값이 꼭 있어야 한다.
만약, 옵셔널이 아닌 값으로 선언되어 있는 게 아니라 옵셔널로 선언되어 있다면?
굳이 초기값을 할당하지 않아도 된다!
옵셔널의 사용과 사용자 정의 이니셜라이저를 적절히 혼합한 방법은 다음과 같다.
struct CoordinatePoint {
var x: Int
var y: Int
}
class Position {
var point: CoordinatePoint?
let name: String
init(name: String) {
self.name = name
}
}
// 이름은 옵셔널이 아닌 값이기 때문에 반드시 값을 넣어야 한다.
let myPosition: Position = Position(name: "silverCastle")
// 위치를 알게되면 그 때 위치 값을 할당해준다.
myPosition.point = CoordinatePoint(x: 2, y: 4)
'lazy'라는 단어 의미 그대로 게으른, 느긋한 저장 프로퍼티이다. 즉, 필요하기 전까지 미루다가 필요할 때 값이 할당된다. lazy 키워드를 사용한다.
지연 저장 프로퍼티는 주로 복잡한 클래스나 구조체를 구현할 때 많이 사용되는데 인스턴스를 초기화하면서 저장 프로퍼티로 쓰이는 인스턴스들이 한번에 생되어야 하거나 굳이 모든 저장 프로퍼티를 사용할 필요가 없을 경우 사용된다.
불필요한 성능저하나 공간 낭비를 줄일 수 있다는 장점이 있다.
지연 저장 프로퍼티를 선언하는 방법은 다음과 같다.
struct CoordinatePoint {
var x: Int = 0
var y: Int = 0
}
class Position {
lazy var point: CoordinatePoint = CoordinatePoint()
let name: String
init(name: String) {
self.name = name
}
}
let myPosition: Position = Position(name: "silverCastle")
// 필요한 시점인 지금, point 프로퍼티의 CoordinatePoint가 생성된다.
print(myPosition.point)
결과
CoordinatePoint(x: 0, y: 0)
실제 값을 저장하는 프로퍼티가 아니라, 특정 상태에 따른 값을 연산하는 프로퍼티이다.
인스턴스 내/외부의 값을 연산하여 적절한 값을 돌려주는 접근자(getter)의 역할과 은닉화된 내부의 프로퍼티 값을 간접적으로 설정하는 설정자
(setter)의 역할을 할 수도 있다.
굳이 메서드를 두고 왜 연산 프로퍼티를 쓰는지에 대한 궁금점이 들 수도 있으니 다음을 보자.
메서드의 경우
struct CoordinatePoint {
var x: Int
var y: Int
// 대칭점을 구하는 메서드 - 접근자
// Self는 타입 자기 자신을 의미, CoordinatePoint를 써도 무방
func oppositePoint() -> Self {
return CoordinatePoint(x: -x, y: -y)
}
// 대칭점을 설정하는 메서드 - 설정자
mutating func setOppositePoint(_ opposite: CoordinatePoint) {
x = -opposite.x
y = -opposite.y
}
}
var myPosition: CoordinatePoint = CoordinatePoint(x: 2, y: 4)
print(myPosition)
print(myPosition.oppositePoint())
myPosition.setOppositePoint(CoordinatePoint(x: 20, y: 40))
print(myPosition)
연산 프로퍼티의 경우
struct CoordinatePoint {
var x: Int
var y: Int
var oppositePoint: CoordinatePoint {
// 접근자
get {
return CoordinatePoint(x: -x, y: -y)
}
// 설정자
set(opposite) {
x = -opposite.x
y = -opposite.y
}
}
}
var myPosition: CoordinatePoint = CoordinatePoint(x: 2, y: 4)
print(myPosition)
print(myPosition.oppositePoint)
myPosition.oppositePoint = CoordinatePoint(x: 20, y: 40)
print(myPosition)
어떤가? 연산 프로퍼티를 사용하면 하나의 프로퍼티에 접근자와 설정자가 모두 모여있고, 다른 프로그래머가 봐도 해당 프로퍼티가 어떤 역할을 하는지 좀 더 명확하게 알 수 있다.
프로퍼티의 값이 변하는 것을 감시하고 프로퍼티의 값이 변경됨에 따라 적절한 작업을 취할 수 있다. 프로퍼티의 값이 새로 할당될 때마다 호출되는데 변경되는 값이 현재의 값과 같더라도 호출함을 기억하자.
프로퍼티의 값이 변경되기 직전에 호출하는 willSet 메서드와 프로퍼티의 값이 변경된 직후에 호출하는 didSet 메서드가 있다.
각각의 메서드는 매개변수가 하나씩 있는데 willSet의 경우에는 프로퍼티가 변경될 값이고, didSet의 경우에는 프로퍼티가 변경되기 전의 값이다.
매개변수의 이름을 따로 지정하지 않으면 willSet의 매개변수 이름은 newValue, didSet의 매개변수 이름은 oldValue이다.
저장 프로퍼티에 프로퍼티 감시자를 구현한 코드는 다음과 같다.
class Account {
var credit: Int = 0{
willSet {
print("잔액이 \(credit)원에서 \(newValue)원으로 변경될 예정입니다.")
}
didSet {
print("잔액이 \(oldValue)원에서 \(credit)원으로 변경되었습니다.")
}
}
}
let myAccount: Account = Account()
myAccount.credit = 100000
결과
잔액이 0원에서 100000원으로 변경될 예정입니다.
잔액이 0원에서 100000원으로 변경되었습니다.
앞서 설명한 연산 프로퍼티와 프로퍼티 감시자는 프로퍼티에 한정하지 않고, 전역변수와 지역변수 모두에 사용할 수 있다.
지금까지 알아본 프로퍼티 개념은 모두 타입을 정의하고 해당 타입의 인스턴스가 생성되었을 때 사용할 수 있는 인스턴스 프로퍼티인 거에 비해 각각의 인스턴스가 아닌 타입 자체에 속하는 프로퍼티이다.
인스턴스의 생성 여부와 상관없이 타입 프로퍼티의 값은 하나이며, 그 타입의 모든 인스턴스가 공통으로 사용하는 값과 모든 인스턴스에서 공용으로 접근하고 값을 변경할 수 있는 변수 등을 정의할 때 유용하다.
타입 프로퍼티와 인스턴스 프로퍼티의 차이를 확인하는 코드는 다음과 같다.
class AClass {
// 저장 타입 프로퍼티
static var typeProperty: Int = 0
// 저장 인스턴스 프로퍼티
var instanceProperty: Int = 0 {
didSet {
Self.typeProperty = instanceProperty + 100
}
}
// 연산 타입 프로퍼티
static var typeComputedProperty: Int {
get {
return typeProperty
}
set {
typeProperty = newValue
}
}
}
AClass.typeProperty = 123
let classInstance: AClass = AClass()
classInstance.instanceProperty = 100
print(AClass.typeProperty)
print(AClass.typeComputedProperty)
값을 바로 꺼내오는 것이 아닌 프로퍼티도 함수와 같이 어떤 프로퍼티의 위치만 참조하도록 할 수 있는데 이것이 바로 키 경로를 활용하는 방법이다.
키 경로를 사용하여 간접적으로 특정 타입의 어떤 프로퍼티 값을 가리켜야할지 미리 지정해두고 사용할 수 있다.
키 경로 타입의 타입 확인하는 코드는 다음과 같다.
class Person {
var name: String
init(name: String) {
self.name = name
}
}
struct Info {
var name: String
var age: Int
}
print(type(of: \Person.name))
print(type(of: \Info.name))
결과
ReferenceWritableKeyPath<Person, String>
WritableKeyPath<Info, String>