값 타입과 참조 타입
값 타입(구조체, 열거형, 튜플)과 참조 타입(클래스) 사이에는 몇 가지 근본적인 차이가 있다. 주된 차이점은 값 타입이나 참조 타입의 인스턴스가 전달되는 방식에 있다. 값 타입의 인스턴스를 전달하는 경우 원본(원본의 인스턴스)의 복사본이 전달된다. 이는 한 인스턴스가 변경 되더라도 다른 인스턴스에 영향을 끼치지 않는다는 의미다. 참조 타입은 이와 달리 원본의 참조를 전달한다. 이는 두 참조 모두 같은 인스턴스를 가리키고 있다는 의미며, 참조의 변경에 다른 참조가 영향을 끼침을 의미한다.
값 타입과 참조 타입 사이의 차이점을 분석해 각각의 장점과 사용 시 주의해야 할 사항을 알아보자.
구조체와 클래스 두 타입을 생성해보자.
struct MyValueType {
var name: String
var assignment: String
var grade: Int
}
class MyReferenceType {
var name: String
var assignment: String
var grade: Int
init(name: String, assignment: String, grade: Int) {
self.name = name
self.assignment = assignment
self.grade = grade
}
}
위에 작성한 두 타입 모두 동일한 세 개의 프로퍼티를 정의했다.
MyReferenceType 타입은 생성자를 정의했지만,
MyValueType 타입은 정의하지 않았다.
구조체는 기본적으로 기본 생성자를 제공하지 않으면 초기화가 필요한
모든 프로퍼티를 초기화할 기본 생성자를 제공하기 때문이다.
반면, 클래스는 반드시 기본 생성자를 정의해야 한다.
위 타입의 인스턴스를 생성해보자.
var refType = MyReferenceType(name: "Kim", assignment: "Math", grade: 50)
var valType = MyValueType(name: "Kim", assignment: "Math", grade: 50)
두 타입 모두 동일한 방식으로 인스턴스를 생성할 수 있다. 그러나 값 타입은 참조 타입과 다른 방식으로 동작한다는 점을 명심해야 한다.
두 타입 인스턴스의 학점을 변경하는 함수를 만들어보자.
func extraCreditReferenceType(ref: MyReferenceType, extraCredit: Int) {
ref.grade += extraCredit
}
func extraCreditValueType(val: MyValueType, extraCredit: Int) {
var _val = val
_val.grade += extraCredit
}
각각의 함수는 하나의 인스턴스와 추가 학점을 인자로 갖는다. 함수 내부에서는 학점에 추가 학점을 더할 더한다. 이 함수를 실행해본 결과값을 출력해보자.
extraCreditReferenceType(ref: refType, extraCredit: 10)
print("Reference: \(refType.name): \(refType.grade)") // Reference: Kim: 60
extraCreditValueType(val: valType, extraCredit: 10)
print("Value: \(valType.name): \(valType.grade)") // Value: Kim: 50
위 결과값을 보면, refType 의 grade 프로퍼티의 값은 변경된 반면
valType 의 grade 프로터피의 값은 변경되지 않았음을 확인할 수 있다.
이는 함수의 값 타입의 인스턴스를 전달하는 경우 실제로는 원본 인스턴스의 복사본을 전달하기 때문이다.
값 타입 인스턴스는 이를 생성한 함수나 타입에 한정되므로 값 타입을 사용하면 예상치 못한 변화로부터 인스턴스를 보호할 수 있다. 또한 값 타입은 같은 여러 개의 참조가 같은 인스턴스를 참조하지 않게 보호해준다.
다음은 참조 타입을 사용하는 경우 마주칠 수 있는 문제다.
func getGradeForAssignment(assignment: MyReferenceType) {
// DB에서 학점을 가져옴
let num = Int.random(in: 20...80) // 랜덤값 생성
assignment.grade = num
print("Grade for \(assignment.name) is \(num)")
}
이 함수는 전달받은 MyReferenceType 인스턴스에서 정의한 name 과 assignment 의 학점을
검색하게 구현돼 있다. 한 번 검색한 학점은 MyReferenceType 인스턴스의 grade 프로퍼티의 값을 저장하는 데 사용한다.
그러나 이 함수를 사용해보면 문제점을 발견할 수 있다.
var mathGrades = [MyReferenceType]()
var students = ["Jon", "Kim", "Kailey", "Kara"]
var mathAssignment = MyReferenceType(name: "", assignment: "MathAssignment", grade: 0)
for student in students {
mathAssignment.name = student
getGradeForAssignment(assignment: mathAssignment)
mathGrades.append(mathAssignment)
}
위 코드를 실행하면 다음과 같이 출력된다.
// Grade for Jon is 65
// Grade for Kim is 77
// Grade for Kailey is 67
// Grade for Kara is 72
출력이 올바르게 되었다고 생각되는가? 그렇다면 최종적으로 저장된 mathGrades 를 순회해보자.
for mathgrade in mathGrades {
print("\(mathgrade.name): \(mathgrade.grade)")
}
위 코드를 순회한 결과값은 다음과 같다.
// Kara: 72
// Kara: 72
// Kara: 72
// Kara: 72
결과값은 기대했던 맨 처음 함수 호출 시 출력했던 값과 다르다. 이는 인스턴스를 하나 생성한 다음 계속해서 단일 인스턴스를 업데이트했기 때문이다. 즉 기존에 있던 이름과 학점에 계속해서 값을 덮어썼다는 것을 의마한다.
MyReferenceType 타입은 참조 타입이므로 mathGrades 배열 안에 있는 모든 참조는
같은 MyReferenceType 인스턴스를 가리킨다. 그러므로 참조하는 값을 출력 시 같은 값을 출력하게 된다.
이러한 유형의 문제에 주의해야 함을 알고 있지만 여전히 발생하는 문제다. 특히 신입 개발자가 실수하기 좋다.
값 타입을 사용하면 이러한 문제를 해결하는 데 도움이 된다. 그러나 때로는 이런 방식의 동작이 필요할 때도 있다.
애플은 위 같은 동작을 수행할 수 있도록 inout 매개변수를 사용해 처리하는 방식을 제공한다.
inout 매개변수는 값 타입의 매개변수 값을 변경할 수 있게 해주며, 함수 호출이 끝나더라도 변경 사항을 계속해서 유지하게 해준다.
inout 매개변수는 매개변수를 정의하는 맨 앞에 inout 키워드를 추가해 정의할 수 있다.
inout 매개변수는 함수로 전달되는 값이다. 이 값은 함수 내부에서 변경되고 다시 함수 밖으로 전달되어 원본값을 대체한다.
이번에는 inout 키워드를 갖는 값 타입을 사용해 위 함수를 올바르게 동작하게 바꿔보자.
func getGradeForAssignment2(assignment: inout MyValueType) {
// DB에서 학점을 가져옴
let num = Int.random(in: 20...80) // 랜덤값 생성
assignment.grade = num
print("Grade for \(assignment.name) is \(num)")
}
var mathGrades2 = [MyValueType]()
var students = ["Jon", "Kim", "Kailey", "Kara"]
var mathAssignment2 = MyValueType(name: "", assignment: "MathAssignment", grade: 0)
for student in students {
mathAssignment2.name = student
getGradeForAssignment2(assignment: &mathAssignment2)
mathGrades2.append(mathAssignment2)
}
위 코드는 앞전 예제와 비슷하지만 조금 다른 점이 있다.
반복문에 활용되는 선언한 변수가 MyValueType 타입으로 값 타입이며,
함수 호출 시 & 키워드를 통해 값 타입의 참조를 전달한다는 점이다.
이로 인해 함수 내부에서 생기는 변화가 원본 인스턴스에 영항을 끼치게 된다.
위 코드를 실행하면 다음과 같은 결과값이 출력된다.
// Grade for Jon is 29
// Grade for Kim is 64
// Grade for Kailey is 75
// Grade for Kara is 38
// 생성 시 동일한 랜던함 값이 출력됨
// Jon: 29
// Kim: 64
// Kailey: 75
// Kara: 38
여기에서 드는 의문점
inout방식이 새로운 구조체를 생성하는 것보다 나을까?[1]
[1]
위에서 작성한 inout 함수를 호출하는 반복문은 다음과 같이 변경해도 동일한 결과를 얻을 수 있다.
for student in students {
let num = Int.random(in: 20...80)
let newMyValueType = MyValueType(name: student, assignment: "MathAssignment", grade: num)
mathGrades2.append(newMyValueType)
}
위 코드는 반복문안에서 새로운 MyValueType 타입의 인스턴스를 생성한다. 그 후 배열의 반복문을 실행시켜보자.
for mathgrade in mathGrades2 {
print("\(mathgrade.name): \(mathgrade.grade)")
}
출력한 결과는 다음과 같다.
// 랜던함 값이 출력됨
// Jon: 48
// Kim: 67
// Kailey: 51
// Kara: 78
inout 이 일반적으로 전달되는 값의 상태를 변경하거나 값의 교환에 효과적이긴 하지만
반드시 해당 방법이 필요한지 생각해볼 필요가 있지 않을까 생각이 든다. 상황에 따라 서로 더 나은 상황이 있을 것 같다.
또한 inout 파라미터는 몇 가지 제약사항이 있으므로 사용 조건에 맞는지 잘 확인해보자.