-
Optional Deep Dive
optional은 swift programming에서 가장 자주 사용하는 문법중 하나이다. 하지만, (이전의 나 역시도) optional이 어떤 형태를 갖추었고 어떻게 구현되어있는지 ‘정확히’ 알지 못하여 이번 기회를 통해 알게 된 것들을 기록하고 서술한다.
Optional is Enum
optional은 단지 enum 이다. 이는 구현부를 보게되면 쉽게 확인해 볼 수 있다.
@frozen public enum MyOptional<Wrapped>: ExpressibleByNilLiteral { case none case some(Wrapped)
이를 기반으로 이런식으로 enum형태로 optinal을 사용할 수 있다.
func returnSomeOptional(_ num: Int) -> Optional<Int> { if num % 2 == 0 { return .some(num) } else { return .none } }
이걸 Short 버전으로 줄인 버전이 바로 우리가 흔히쓰는 옵셔널 문법이다.
// shorterWay func returnSomeOptinal(_ num: Int) -> Int? { if num % 2 == 0 { return num } else { return nil } }
Switch Optional
이어 옵셔널이 enum이기 때문에 다음과 같이 switch문으로도 사용 가능하다.
func switchSomeOptional(_ num: Int?) -> String { switch num { case 22?: return "hello 22?" case (1..<100)?: return "1~100" case let number?: return "number: \(number)" case nil: return "nil" } }
Nil-coalescing operator
옵셔널의 ?? 연산자를 통해 기존보다(연산자를 사용하기 전보다) 더욱 쉽게 옵셔널을 언래핑 할 수 있다. 이어, 실패할 경우 Default Value를 리턴하도록 한다.
var name = BookStore.store?.book?.name ?? "Default Value" print(name)
구현을 보게 되면,
public func ?? <T>(optional: T?, defaultValue: @autoclosure () throws -> T) rethrows -> T { switch optional { case .some(let value): return value case .none: return try defaultValue() } }
사실 설정했던 Default Value가 closure라는 사실을 알 수 있다. 최적화 관점에서, 디폴트 값을 실제로 사용하지 않을시, 클로져는 실행되지 않기 때문에 효율성이 증가한다.
표현식으로는 @autoclosure를 사용하여 (클로져임에도 불구하고) 괄호를 생략하여 사용 가능 했던 것 이였다.
map & flatMap
Swift에서는 collection뿐만아니라 optional도 값 변환을 위한 오퍼레이션인 map과 flatMap을 제공한다.
1. Map
let number: Int? = Int("100") let mappedNumber = number.map { (Int("\($0 + $0)")) } print(mappedNumber) // prints: Optional(Optional(200))
2. flatMap
Map과 거의 같지만, flatMap은 말그대로 값을 한번 flat하게 처리한다.let number: Int? = Int("100") let mappedNumber = number.flatMap { (Int("\($0 + $0)")) } print(mappedNumber) // prints: Optional(200)
좀더 자세한 차이를 살펴보려면 구현부를 찾아보면 된다.
@inlinable public func map<U>( _ transform: (Wrapped) throws -> U ) rethrows -> U? { switch self { case .some(let y): return .some(try transform(y)) // some에 담아서 처리 case .none: return .none } } @inlinable public func flatMap<U>( _ transform: (Wrapped) throws -> U? ) rethrows -> U? { switch self { case .some(let y): return try transform(y) case .none: return .none } }
map의 경우 .some(try transform(y))와 같이 처리하여, 말 그대로 map을 수행하는 self객체(optional)을 감싸서 반환하게 된다. 하지만 flatMap의 경우, 바로 try transform(y)으로 받은 클로져값을 그대로 리턴해준다.
전체 Custom Optional Implementation
// // MyOptinal.swift // // Created by 이상헌 on 2023/10/02. // @frozen public enum MyOptional<Wrapped>: ExpressibleByNilLiteral { case none case some(Wrapped) @_transparent public init(_ some: Wrapped) { self = .some(some) } @inlinable public func map<U>( _ transform: (Wrapped) throws -> U ) rethrows -> U? { switch self { case .some(let y): return .some(try transform(y)) case .none: return .none } } @inlinable public func flatMap<U>( _ transform: (Wrapped) throws -> U? ) rethrows -> U? { switch self { case .some(let y): return try transform(y) case .none: return .none } } // var i: Index? = nil @_transparent public init(nilLiteral: ()) { self = .none } @inlinable public var unsafelyUnwrapped: Wrapped { @inline(__always) get { if let x = self { return x } fatalError("unsafelyUnwrapped of nil optional") } } @inlinable internal var _unsafelyUnwrappedUnchecked: Wrapped { @inline(__always) get { if let x = self { return x } fatalError("_unsafelyUnwrappedUnchecked of nil optional") } } /// if let numberOfShoes = numberOfShoes.take() { /// print(numberOfShoes) /// // Prints "34" /// } internal mutating func _take() -> Wrapped? { switch self { case .some(let wrapped): self = nil return wrapped case .none: return nil } } } extension MyOptional: Equatable where Wrapped: Equatable { public static func ==(lhs: Wrapped?, rhs: Wrapped?) -> Bool { switch (lhs, rhs) { case let (l?, r?): return l == r case (nil, nil): return true default: return false } } } // MARK: - Hashable extension MyOptional: Hashable where Wrapped: Hashable { @inlinable public func hash(into hasher: inout Hasher) { switch self { case .none: hasher.combine(0 as UInt8) case .some(let wrapped): hasher.combine(1 as UInt8) hasher.combine(wrapped) } } } // MARK: - ?? operator extension MyOptional { @inlinable static public func ?? <T>(optional: T?, defaultValue: @autoclosure () throws -> T) rethrows -> T { switch optional { case .some(let value): return value case .none: return try defaultValue() } } }
+)Ref:
https://github.com/apple/swift https://agostini.tech/2017/09/24/understanding-optionals/ https://developer.apple.com/documentation/swift/optional/flatmap(_:) -
Type System In Swift
이번 글에서는, Generic, Protocol, any, some 이 4개의 것들이 Swift 타입 시스템에서 어떠한 역할을 하고 있는지 서술하려고 한다.
Generic & Protocol
제네릭스를 이용하면 여러 타입에 공통적으로 사용할 수 있는 함수와 자료형을 정의할 수 있다. 뿐만 아니라 Protocol을 사용하여 타입에 제약조건을 걸어 사용할 수도 있다.
// 보일러 코드들 protocol Fixable { func fix() -> String } struct LapTop: Fixable { func fix() -> String { return "fix labtop" } } struct DeskTop: Fixable { func fix() -> String { return "" } } func isFixing<Target: Fixable>(of computer: Target) -> Bool { return computer.fix() != "" }
이런식으로 각 Target타입이 Fixable만을 채택후 구현을 해주면 코드를 공유할 수 있다.
타입으로서의 Protocol & Generic
위의 제네릭 방법뿐만 아니라 protocol 자체를 타입으로도 지정해 줄수 있다.
func isFixing(computer: Fixable) -> Bool { return computer.fix() != "" }
그러면 Generic과 Protocol의 타입으로서의 차이점은?
struct QueueExistential { var queue: [Fixable] } struct QueueGeneric<Target: Fixable> { var queue: [Target] } let laptop = LapTop() let desktop = DeskTop() // 1 - use protocol //✅ Compile Success let queueExistential = QueueExistential(queue: [laptop, desktop]) // 1 - use generic // 🔴 Error: type 'any Fixable' cannot conform to 'Fixable', note: only concrete types such as structs, enums and classes can conform to protocols let queueGeneric = QueueGeneric(queue: [laptop, desktop])
이 예시에서는 protocol을 사용한 Queue는 컴파일이 되고 Generic은 Error가 난다. 왜 이런 차이가 벌어지게 되는 것일까?Generic은 컴파일시 프로토콜을 준수하는 구체적인 타입이 필요하다. 단순히 Fixable protocol을 이용해 어떠한 타입의 인자를 받도록 “제한” 하는 것이지 Fixable한 다양한 타입을 받도록 하는것은 아니다. 이어 제네릭을 타입단위의 추상화 라고도 한다.
반면 Protocol은 프로토콜을 준수하는 모든 타입을 참조할 수 있다. 해당 프로토콜을 만족하기만 한다면 어떤 값이든 들어갈 수 있기 때문에 값 단위의 추상화 라고도 부른다. 이 차이를 잘 이해하는 것 이 중요하다.
Existential any
any를 설명하기에 앞서, 해당 코드를 보자let num: Equatable = 1 let text: Equatable = "st" // 🔴 error: use of protocol 'Equatable' as a type must be written 'any Equatable'
Equatable를 타입으로는 지정할 수 없고, 타입으로 지정하려면 ‘any Equatable’를 쓰라고 에러가 나온다.
protocol Equatable
가 num,text 의type
으로 사용되었으므로, 이 맥락에서Equatable
는 existential type 이다.
(existential type: protocol 이 type 으로 사용될 때를 칭함)
보통 구체적인 타입을 프로토콜 타입으로 안전하게 치환할 수 있는 경우를
‘covariant’
하다고 한다. 보통 associatedtype을 가지고 있거나(구현이 필요함), equatable의 연산자와 같이static func == (lhs: Self, rhs: Self) -> Bool
lhs와 rhs의 구체적인 타입이 일치하지 않을 경우 Equatable에서 안정성 문제가 발생한다. (
Non-covariant
) 이렇게 제약 조건으로서의 프로토콜과 타입으로서의 프토토콜은 다른 개념으로 볼수가 있기 때문에 any Protocol이라는 개념이 등장하게 된다.
프로토콜 ‘타입’에는 any를 붙힘으로써 제약조건으로서의 프로토콜과 구분을 두는 것이다. 이뿐 아니라 existential type 은 concrete type의 비용 차이 문제와 더불어 (dynamic vs static) 가시성에서 두 타입(existential vs concrete)에 대한 구분이 가지 않는 점을 근거로 swift 5.6 에 Existential any 가 release 하게 된다.
Opauqe Type
직역하자면 ‘불분명한 타입’ 이라 한다.
다른 이름으로는 역 제네릭 타입(reverse generic type) 이라고 불르기도 한다. 그에 대한 이유는 Opauqe Type의 특징과 연관이 있다.기존 generic으로 작성된 함수는
struct Stack<Element> { var items: [Element] = [] mutating func push(_ item: Element) { items.append(item) } mutating func pop() -> Element { return items.removeLast() } }
구현부: 추상화 하여 작성
호출부: 구체적인 타입 지정( 여기서 타입 지정 )
와 같은 특징을 지닌다.
이제
opeque type
을 살펴보자. opeque typesome 키워드 + 프로토콜
로 사용한다.
func makeArray() -> some Collection { return [1, 2, 3] }
제네릭과 반대로 makeArray 함수 안에서 [Int]라는 구체타입을 알게 된다. 그리고 호출부 입장에서는 추상적인 타입인 ‘Collection’ 타입 밖에 알지 못한다.
그래서 정리하자면
구현부: 구체적인 타입 지정 (여기서 타입 지정)
호출부: 추상타입
해서 이러한 특성으로 인해
"Opaque Type을 사용하여 리턴 타입의 정보를 숨길 수 있다”
라고도 말함 또한 Opaque Type을 사용하게 되면 호출부에게 타입을 숨기면서 컴파일러는 구체타입을 알기 때문에 Type Identity를 preserve(보존)할 수 있다라고도 말할수 있습니다.
+) Opeque Type은 해당 용도로 사용될 수 있습니다.- 함수(메소드)의 리턴타입
- stored property 타입
- computed property 타입
- subscripts
- (Swift 5.7 ~) 함수 파라미터 타입
+) Ref
https://forums.swift.org/t/improving-the-ui-of-generics/22814#heading–clarifying-existentials
https://developer.apple.com/videos/play/wwdc2022/110352/
https://docs.swift.org/swift-book/documentation/the-swift-programming-language/protocols/
https://developer.apple.com/videos/play/wwdc2022/110353/
https://zeddios.tistory.com/1348
https://github.com/apple/swift-evolution/blob/main/proposals/0335-existential-any.md
https://engineering.linecorp.com/ko/blog/about-swift-type-system#1
-
Hash In Swift
Direct-Address-Table
Key값으로 k를 갖는 원소는 index k에 저장하는 방식의 테이블이다.(array 사용)
하지만 이렇게되면, 크게 두가지 문제가 발생한다.
1. 불필요한 공간 낭비
- Ex) key가 만약 2000번대 부터 시작한다면 0~1999의 인덱스의 공간은 낭비하게 된다.
2. key가 다양한 자료형 지원 x
- Ex) key값이 만약 String이라면, index에 어떤 기준으로 저장해야할지 방법이 전무 -> key값이 String이면 저장을 할수 없게 됨.
이와 같은 문제들을 해결한 버전(?)이 바로 Hash Table 개념이다.
Hash Definition
해쉬테이블을 제대로 설명하기전, 해싱,해쉬, 해쉬 함수 등의 용어정리를 먼저 정리해보자.
- Hash
“해시(hash)”는 일반적으로 무언가를 잘게 쪼갠 후에 결과물을 생성하는 과정을 의미 Ex) 해쉬 브라운, 해쉬 태그
- Hash Function
위의 해쉬 개념을 컴퓨터 과학에 적용시켜 해쉬 함수를 다음과 같이 서술합니다.임의의 길이를 가진 데이터를 입력받아 고정된 길이의 값, 즉 해시값을 출력하는 함수 해시 함수를 사용하면 입력값에 대해 항상 동일한 규칙에 따라 고정된 길이의 해시값을 생성
- Hash Value
해시 값(해시 또는 체크섬이라고도 함)은 특정 길이의 문자열 값으로, 해싱 함수의 계산 결과
- Hashing
매핑 전 원래 데이터의 값을 키(key), 매핑 후 데이터의 값을 해시값(hash value), 매핑하는 과정 자체를 해싱(hashing)
Hash Table
앞서 말한, Direct-Address-Table의 문제점을 해결하기 위해 Hash Table은 hash function ‘h’ 를 사용하여 (key: value)를 인덱스 h(k) 에 저장한다.
** **주로 키 k값을 갖는 원소가 위치 h(k)에 해쉬된다. 라고 표현하기도 한다. 그리고 여기서 전제조건은 key가 무조건 존재해야 하며 중복되서도 안된다
slot, bucket: hash table을 구성하는 (key:value)를 저장하는 각각의 공간을 칭함.
좀 더 구체적으로, Hash Table을 사용해 Direct-Address-Table의 문제점을 어떻게 해결할까?
-
공간 낭비 문제 방금과 같이 2000의 값을 가지는 key가 있다고 가정하면, 해쉬 테이블은 Hash Function을 활용해 키값을 넣어 해쉬값을 만들어 내고 그값을 인덱스로 사용하여 효율성을 높인다.
예를들어, k modular 9 이라는 식의 해쉬 함수가 있다면 2000을 9로 나눈 나머지니깐 2가 나온다. 그럼 index0 ~ 2000이 아닌 index 0 ~ 2만 써도 되니 불필요한 공간 논리 상, 1998을 아낄 수 가 있다. (원리가 이렇다는 것)
-
Key 자료형 지원 문제 문제예시와 같이 key값이 String이 오게 된다면, 마찬가지로 해쉬 함수를 통해 이 스트링 값을 hash value로 치환해준다.(나름의 알고리즘 존재) 그리고는 그 값을 인덱싱하는데 쓸 수 있게 되는 것 이다.
그러면 Hash Table은 마냥 해피해피한 자료구조일까?
그렇지 않다. Hash Table도 여러가지 문제점을 갖고 있지만 가장 대표적인 문제점은 Colision 문제이다.
Colision
서로 다른 Key의 해쉬값이 값을 때를 칭함. ( 중복되는 key는 없지만 hash value는 중복될 수 있다.)
그러므로, colision이 최대한 적게 나도록 해쉬 함수를 잘 설계해야하고, 발생하는 경우는 chaning 또는 open addressing등의 방법을 고려한다. (이외에도 많음)
Colision을 해결하는 방법
여러가지 방법이 있지만, 이 섹터에서 소개할건 open addressing방식과 chaning 방식이다.
- Open Addressing
Collision이 발생하면 미리 정한 규칙에 따라 hash table의 비어있는 slot을 찾는다. (추가적인 메모리 사용 x)
slot을 찾는 방식에 따라 Linear Probing, Quadratic Probing, Double Hashing 등이 있음
1. Linear Probing(선형 조사법) & Quadratic Probing(이차 조사법)
colision발생시, 발생한 해시값으로 부터 일정한 값만큼 뛰어 넘어, 비어있는 slot에 데이터를 저장 Linear(+1,2,3) Quadratic (1^2, 2^2,3^2) colision이 많아지면 특정 영역에 데이터가 몰리는 클러스터링이 발생 할수도 있음. 클러스터링이 발생하면 평균 탐색 시간이 증가2. Double Hasing(이중 해싱)
앞서말한 클러스터링 문제가 발생하지 않도록, 2개의 해쉬 함수를 사용함 첫번째 해쉬함수: 최초의 해쉬값을 얻을때 두번째 해쉬함수: colision발생 시, 탐사 이동폭을 얻기 위해
- Separate Chaning
Linked List 또는 Tree를 사용한다. Collision 발생 시, Linked List에 노드(slot)을 추가하여 데이터를 저장 (Worst case로 N개의 모든 key가 동일한 해쉬값을 가지는 경우 n개의 길이를 가진 Linked List가 만들어짐
-> 검색 및 삭제 O(n) (기본은 O(1)))
Swift Hashable Protocol
Hashable 프로토콜은 해싱에 사용할 수 있는 해시 값을 생성하는 기능을 정의한다. 또한 여기서 해시 값은 고정 길이의 값을 가지며, 주로 컬렉션에서 빠른 검색을 위해 사용된다. Hashable 프로토콜을 준수하는 타입은 ` func hash(into hasher: inout Hasher)` 함수를 구현해야 한다. (Swift의 기본 타입은 기본적으로 Hasable하다.)
- Hasher(?)
주석을 읽어보니 hashValue가 deprecated 되고 hash함수가 그 자리를 메운거 같음
Hasher는 Set과 Dictionary에서 사용되는 범용 해시 객체
Hasher를 사용하여 임의의 바이트 시퀀스를 정수 해시 값으로 매핑할 수 있다고 함.
또한 combine 메서드를 사용하여 데이터를 해시에 추가할 수 있으며, 해시 작업을 완료한 후 finalize()를 호출하여 해시 값을 검색하는 것도 가능하다고 한다.
warning: Hasher의 구현 알고리즘이 라이브러리의 버전 간에 변경될 수 있으므로, 프로그램 실행 간에 해시 값을 저장하거나 재사용하지 말것.
한번 Hashable프로토콜로 커스텀 Key값을 만들어 보자.
struct MyMacBook: Hashable { var name: String var price: Int func hash(into hasher: inout Hasher) { hasher.combine(name) // 데이터 추가 hasher.combine(price) // 데이터 추가 print(hasher.finalize()) // ex) -7858459044973498536 } } let M1 = MyMacBook(name: "M1", price: 100) let M2 = MyMacBook(name: "M2", price: 200) // 구조체를 키값으로~ let macBookDict: [MyMacBook: String] = [M1: "M1 구입했구나", M2: "M2 구입했구나"] let BuyM1 = macBookDict[MyMacBook(name: "M1", price: 100)] // -> "M1 구입했구나"
추가로 Hasable, Equatable 프로토콜을 채택해주면 컴파일러가 자동으로 hash(into:) 함수를 구현해준다. 혹은 위 예제처럼 직접 함수를 구현해줄 수 있다. Swift 4.1 이전까지는 자동으로 구현되지 않아 반드시 사용자가 hash(into:) 함수와 == 연산자를 구현해주어야 했다고 함
재밌는건 이 부분이 Set의 Contain함수가 O(1)인 이유를 설명해 준다는 것이다. Set의 구현체를 보면@frozen @_eagerMove public struct Set<Element: Hashable> { @usableFromInline internal var _variant: _Variant
이처럼 원소를 hashable 타입으로 받고있다. 그래서 일반적으로 Set은 일반적으로 키와 값이 동일한 해시맵으로 구현되거나 한다.(아닌 경우가 있을수 있지만)
결국 이런 각 고유한 해쉬 값을 통해 값의 조회가 가능하니 O(1)로 조회가 가능한 것이다.
또한 같은 이유로 Set에 어떤 커스텀 타입을 넣고 싶다면 해당 타입은 꼭 Hashable을 준수해야 한다.
Swift는 어떻게 Colision을 처리할까?
더 나아가 이런 궁금증도 생길 수 있다., Swift의 colision이 발생하면 어떤 방식을 사용할까?
Swift는 기본적으로 Cahning을 사용하고, 해시 충돌이 자주 발생할 것으로 예상되는경우, Linear Probing또는 Double hashing을 고려한다고 한다.
더불어, 해쉬 충돌을 예방하기 위해 (충돌 저항성을 높이기 위해) SipHash라는 방법을 사용한다고 한다.
SipHash & Seed
Swift뿐만 아니라 Radis에서도 사용하고 있는 해쉬 알고리즘이다. 임의의 값인 Seed를 활용해 동일한 입력 데이터에 대해 다른 해쉬 값이 생성되도록 하여 해쉬값을 분산시키고 충돌 저항성을 높힌다.
let value = "hello, world" let seed = arc4random() // SipHash를 사용하여 hash값을 생성 let hash = value.hash(into: &seed) // hash값 출력 print(hash)
+) Ref:
https://jeong9216.tistory.com/523 https://ratsgo.github.io/data%20structure&algorithm/2017/10/25/hash/ - Ex) key가 만약 2000번대 부터 시작한다면 0~1999의 인덱스의 공간은 낭비하게 된다.
-
Mutation In Swift
Mutation
: 어떤 것의 형태나 구조의 변화
흔히들 프로그래밍에서 Mutation을 피하라고 한다. 이렇게 말하는 근거가 무엇일까?
- Mutation으로 인해 예상치 못한 디버그하기 어려운 문제가 발생할 수 있다. 즉, 데이터가 어딘가에서 부정확해지며 어디서 발생하는지 알 수 없을 수 있다.
- 변형은 코드를 이해하기 어렵게 만든다. 언제든지 배열이나 객체가 다른 값을 가질 수 있으므로 코드를 읽을 때 주의해야 한다.
- 함수 매개변수를 Mutation하게 만든다면, 함수의 행동은 복잡해질수 있다.
- Mutation은 예측 하기 힘들다. 프로그래밍 중, 어떤 메소드가 원본 데이터를 수정하고 어떤 메소드가 수정하지 않는지 단순 기억력으로는 기억 하기 쉽지 않다.
그렇다면 Mutation이 무조건 나쁜걸까?
아니다. Mutation은 프로그래밍 세계에서 동작을 위해 꼭 필요한 기능이다. 또한 다음과 같은 이유로 활용을 고려해볼수 있다.
- 값을 Mutation을 사용하여 변경하면, 값 타입을 복사하는 비용을 줄일 수 있다. 이는 성능을 향상시킬 수 있다.
- 사용 하기에 따라 유지보수성을 올릴 여지도 있다. 한곳에 모아두고 관리해야 하는 상태가 존재한다면 Mutation을 활용해봄직 하다.
그럼에도 불구하고, Mutation 사용시 Side-Effect가 나오는 경우가 정말x4 많기 때문에 Mutation에 대한 정확한 이해 및 주의가 필요하다.
타입에 따른 변화 관찰
Swift에서는 참조 타입과 값 타입으로 구분하여 Mutation의 동작을 다르게 처리한다. 그러므로 참조타입과 값타입의 행동 원리를 정확히 이해 하는게 중요하다. 이와 관련하여 크게 두가지로 나누어 코드 실행을 통해 살펴보자.
테스트 Class, Struct 작성
// MARK: - class class PersonClass { var name: String var jacket = JacketClass(color: "pulple") init(name: String) { self.name = name } } class JacketClass { var color: String init(color: String) { self.color = color } } // MARK: - struct struct PersonStruct { var name: String var jacket = JacketClass(color: "pulple") init(name: String) { self.name = name } mutating func setName(_ name: String) { self.name = name } } struct JacketStruct { var color: String init(color: String) { self.color = color } } extension PersonStruct: Equatable { static func == (lhs: PersonStruct, rhs: PersonStruct) -> Bool { lhs.name == rhs.name } }
MISSON(1) - 변수에 할당된 인스턴스를 다른 인스턴스로 바꾸기 (let, var 각각 경우)
Class
let person1 = PersonClass(name: "tony") let person2 = PersonClass(name: "mathew") // var var man1 = person1 print("--------Class-------") print(man1.name) print("------------------") man1 = person2 print(man1.name) print("------------------") // let let man2 = person1 print("--------Class-------") print(man2.name) print("------------------") man2 = person2 // Error: Cannot assign to value: 'man2' is a 'let' constant, Change 'let' to 'var' to make it mutable print(man2.name) print("------------------")
Struct
let person3 = PersonStruct(name: "tony") let person4 = PersonStruct(name: "mathew") // var var woman1: PersonStruct = person3 print("--------Struct-------") print(woman1.name) print("------------------") woman1 = person4 print(woman1.name) print("------------------") // let let woman2: PersonStruct = person3 print("--------Struct-------") print(woman2.name) print("------------------") woman2 = person4 // Error: Cannot assign to value: 'man2' is a 'let' constant, Change 'let' to 'var' to make it mutable print(woman2.name) print("------------------")
let은 재할당이 안되고 단 한번만 값이 할당된다. 그러기에 변할 수 없다.
weak let weakMan = PersonClass(name: "weakMan") // Error: 'weak' must be a mutable variable, because it may change at runtime
이것이 우리가
'weak let'
을 쓸수 없는 이유이기도 하다. (weak로 선언된 참조는 인스턴스가 해제되면 자동으로 nil로 설정됨)
MISSON(2) - 할당된 인스턴스의 프로퍼티 값 바꾸기 (let, var 각각 경우)
Class
// var var white1 = PersonClass(name: "oliver") white1.name = "tom" white1.jacket = JacketClass(color: "red") print("------------------") print(white1.name) print(white1.jacket.color) print("------------------") // let let white2 = PersonClass(name: "oliver") white2.name = "tom" white2.jacket = JacketClass(color: "red") print(white2.name) print(white2.jacket.color) print("------------------")
결과:
------------------ tom red ------------------ tom red ------------------
Class의 경우, 프로퍼티를 let과 var 둘다 변경이 가능하다. 어째서 일까?
let white2
변수가 stack에person1: personClass
를 가르키는 주소값을 가지고 있다. 여기서 let이 가지고 있는 상수는 주소값이므로 그 주소값만이 let의 특성인 Immutable해진다. 그러므로 인스턴스의 프로퍼티는 var로 설정했다면 변경이 가능하다.
Struct
print("------------------") // var var black1 = PersonStruct(name: "jamal") black1.name = "darnell" black1.jacket = JacketClass(color: "green") print(black1.name) print(black1.jacket.color) print("------------------") // let let black2 = PersonStruct(name: "jalen") black2.name = "darnell" // Error: Cannot assign to property: 'black2' is a 'let' constant, Change 'let' to 'var' to make it mutable black2.jacket = JacketClass(color: "green") // Error: Cannot assign to property: 'black2' is a 'let' constant, Change 'let' to 'var' to make it mutable black2.setName("darnell") // Error: Cannot assign to property: 'black2' is a 'let' constant, Change 'let' to 'var' to make it mutable print(black2.name) print(black2.jacket.color) print("------------------")
Struct의 경우, let으로 선언이 되면 자신은 물론 프로퍼티수정이 불가능 하다. 어째서 일까? Class와 다르게 참조형태가 아닌, 값 자체들을 (주로) Stack에 저장해두고 있기 때문이다. 그러므로 let에 해당하는것들은 struct값들 그자체이므로 Immutable 해진다. 물론 mutable func도 사용하지 못한다.
Struct with var
var struct
같은 경우는 해당 값들이 정말 변경 되는 것일까? 결론을 먼저 말하자면 “논리적으로 값타입은 Immutable하다”가 맞다.struct Bar { var x: Int var y: Int } struct Foo { var bar: Bar { didSet(oldValue) { print("Got a new bar!") print(oldValue) } } } var foo = Foo(bar: Bar(x: 1, y: 1)) foo.bar.x = 10
이코드를 실행해보면
foo.bar = something
을 안했는데도"Got a new bar!"
가 프린팅 된다. 이로써 알수 있는건 “아.. 변경을 시도하면 값이 안에서 변경되는게 아니라 새로운 값을 복사해서 넣는구나”를 알수 있다. 이를 더 명확하게 증명하는것은 우리가didSet
에서OldValue
를 가져올수 있다는거다. 사용자가 두 버전(old, current)의 값을 동시에 참조할 수 있으므로 컴파일러는 값의 전체 복사본을 만들어야 할거다.
물론 컴파일러의 깊은곳을 들어가면 메모리 값의 실제 수정이 내부에서 발생할 가능성이 있더라도 우리가 코드 쓰는 의미 수준에서 전체 Bar 구조체는 완전히 새로운 값으로 대체 된다고 이해하는 것이 적절하다.
결론
- Swift에서 Mutation은 참조 타입과 값 타입에 따라 동작이 다르다.
- 참조 타입은 인스턴스의 참조를 변경하는 것이고, 값 타입은 인스턴스의 값을 직접 변경하는 것이다.
- 하지만 논리적으로는 값타입은 “변경이 아닌 새로운 값을 복사한다”라고 이해해야 적절하다
+)Ref:
https://forums.swift.org/t/why-are-structs-in-swift-said-to-be-immutable/55319/15 - Mutation으로 인해 예상치 못한 디버그하기 어려운 문제가 발생할 수 있다. 즉, 데이터가 어딘가에서 부정확해지며 어디서 발생하는지 알 수 없을 수 있다.
-
메모리 관리 with arc, gc
메모리 관리
“메모리 관리(memory management)는 컴퓨터 메모리에 적용된 리소스 관리의 일종이다. 가장 단순한 형태의 메모리 관리 방법은 프로그램의 요청이 있을 때, 메모리의 일부를 해당 프로그램에 할당하고, 더 이상 필요하지 않을 때 나중에 다시 사용할 수 있도록 할당을 해제하는 것이다” - 위키피디아
메모리 관리 기법이 필요한 이유 또는 배워야 하는 이유가 뭘까?
여러 이유들이 있겠지만 내가 생각했을때 앱개발자로서 가장 큰 이유는 2가지가 있다.
-
메모리를 최적화 한다면 너 낮은 사양의 기기들을 지원 가능. 더많은 유저 확보 가능
-
더 나은 유저 서비스 제공 가능, 메모리 릭 또는 댕글링 포인터 등의 문제 발생 방어 가능.
메모리 관리 기법 종류들
앱 개발 레이어에서는 메모리 관리 기법을 자동과 수동 기법으로 양분 해볼 수 있다.
-
자동 메모리 관리
- GC(Garbage Collector): 프로그램 실행 중에 사용되지 않는 메모리를 자동으로 해제하는 기법
- ARC(Automatic Reference Counting): 객체의 참조 카운트를 사용하여 객체의 생명주기를 관리하는 기법
-
수동 메모리 관리
- Malloc/Free: 개발자가 직접 메모리를 할당하고 해제하는 기법
- New/Delete: C++에서 사용하는 메모리 관리 기법
MRC
실제로 Objective-C에서는 MRC라는 ‘수동 메모리 관리 기법’을 사용했었다.
- (void)setName:(NSString *)newName { [newName retain]; [name release]; name = newName; }
- retain : retain count(= reference count) 증가를 통해 현재 Scope에서 객체가 유지되는것을 보장
- release : retain count(= reference count)를 감소시킴. retain 후에 필요 없을 때 release 함
이런 식으로 retain과 relase를 사용하여 메모리 관리를 해주고는 했다. 하지만 프로그램이 커질수록 하나하나 인스턴스에 대한 메모리 관리를 해주는 것이 쉽지 않았고 그에 대한 대안으로 Apple에서는 ARC를 소개한다.
ARC vs GC
ARC란 자동 메모리 관리 기법에 속하는 방법 중 하나로 개발자가 신경쓸 필요없이 자동으로 Compile Time에 실행 되어 동적으로 Reference count를 세고 메모리 관리를 해주는 방법이다.
비슷한 방법으로 GC (Garbage Collection) 이라는 Jva or C#, GO 등에서 쓰이는 메모리 관리 기법 이 있는데 차이점을 서술하자면 이렇다.
GC: 이미 할당된 메모리에서 더 이상 사용하지 않는 메모리를 해제하는 기술
- 런타임시, 백그라운에서 사용되지 않는 개체와 개체 그래프를 감지하는 방식으로 작동
- 런타임 메모리가 부족하거나, 특정시간이 경과한 후 등의 불확실한 시점에 작동
- retain cycle을 포함하여 전체 객체 그래프를 한번에 관리
ARC: 수동으로 개발자가 직접 retain/release를 통해 reference counting을 관리해야하는 부분을 자동으로 관리해주는 기술
- 객체가 사용되지 않을때, 실시간으로 메모리에서 release
- 컴파일 타임에 작동, 백그라운드 처리가 없으므로 메모리에 더 효과적
- 하지만 retain cycle을 자동으로 해결 x
+ ARC가 Retain Cycle을 해결 못하는 이유
GC같은 경우 reachable 객체를 실시간으로 살펴보며 작동한다. 이 객체의 외부참조가 없는 것을 감지하면, 서로를 참조하는 객체 그래프 전체를 버리기 때문에 retain cycle이 발생하지 않는다.
반면 ARC는 GC보다 더 낮은 수준에서 작동하며, 참조 카운트를 기반으로 생명 주기를 관리하기 때문에 설계 상, retain cycle을 자동으로 처리할 수는 없다.
이와 같은 문제를 해결하기 위해, strong, weak, unowned과 같은 Storage Modifier가 도입된 것이다.
Reachable: 사용하고 있는 메모리
ARC Proposal
잠깐, 자동 메모리 관리 방법에는 ARC방법 뿐만 아니라 GC도 존재한다. 근데 왜 애플은 GC가 아닌 ARC를 도입 했을까?
사실 애플에서는 GC를 도입한적이 있다. (ios는 아니고 os x 에서만)하지만 금방 os x에서도 deprecated 되었다. (10.5 ~. 10.8 OS X Mountain Lion )
사실 이문제는, 효율성에 대한 문제이다. 두 시스템 모두 장단점이 있으며 애플은 단지 선택했을 뿐이다. GC보다는 ARC가 시스템에 더 효율적이라고 판단한 것이다. 두 기법의 차이점을 통해 구체적으로 그 기준을 살펴보자.
- ARC는 전체적으로 GC보다 느릴수 있다. 애플은 이 문제가 크지 않다고 판단한 듯 하다.
- 앞서 말했듯이, ARC는 Retain Cycle을 자동으로 처리해주지 않지만, 이를 위한 대안으로 Storage Modifier을 제공하고, 해당 부분은 개발자의 몫으로 넘어가길 택했다.
- GC는 ARC보다 더 많은 메모리가 필요하다 (경우에 따라 최대 2배). 애플은 모바일 디바이스의 RAM이 너무 적어서 GC에 낭비할 수없다고 판단한 듯하다. 이어 이 부분은 안드로이드 휴대폰이 기본적으로 더 많은 메모리를 갖는 이유이다. (GC가 없을 경우에 비해 1.8~2.0x정도 많은 메모리를 사용하게 됨)
- GC는 더 빠르게 작동할 수 있지만, 트리거 되는 시점에 잠재적으로 몇 밀리초동안 프로그램이 멈춘다. 만약 중요한 일을 한다면 이 시간은 꽤나 Critical 할수도 있다. 반해 ARC는 약간 느리지만 실행은 원활하다. 애플이 판단하길 잠재적인 문제를 일으킬수 있는 리스크를 지는것 보다 느리더라도 원활한 실행 환경을 더 중요하게 생각했다고 볼 수 있다.
Reference Counting
다시 돌아와서 그럼 ARC를 통한 참조 카운팅이 어떻게 이루어질까. 레퍼런스 카운팅은 그대로 말하자면, 참조 횟수이다. 즉, 이 인스턴스를 누가 얼마나 가르키고 있느냐를 나타낸 것이다. 그 기준은 뭔지 실제 코드를 보며 알아보자
-
Reference Counting ‘플러스’ + 일때
-
인스턴스의 주소값을 변수에 할당할 때
- 인스턴스를 새로 생성하는 경우
var pulpleCar: Car? = Car(wheel: "pulple")
- 기존 인스턴스를 다른 변수에 대입하는 경우
let otherCar = pulpleCar
- 인스턴스를 새로 생성하는 경우
-
-
Reference Counting ‘마이너스’ - 일때
-
Object lifetime은 use-based 이다. 마지막 참조 사용시 직후 release를 실행
-
인스턴스를 가르키던 변수가 메모리에서 해제 되었을 시
위와 같은 이유로 마지막 참조 사용시 직후 release된다면 count - 1
func test() { let traveler1 = Traverler(name: "Lily") // 초기화는 참조카운트를 1로 설정 // traveler2에 대해 retain let traveler2 = traveler1 // release - code // traveler1 참조를 마지막으로 사용한 직후 release 코드 삽입 traveler2.destination = "Big Sur" // traveler2에 대해 release print("Done traveling") }
- nill 지정시
var pulpleCar = nil
-
변수에 다른 인스턴스 대입
변수가 다른 인스터스를 가르키게 된거니 당연하다.
var pulpeCar = redCar
- 프로퍼티의 경우, 속한 클래스 인스턴스가 해제되는 경우
class Person { var name: String var car: Car = Car(wheel: "pulpleCar") } var person: Person? = Person(name: "tonny") person?.car = nil
프로퍼티의 경우 가르키던 인스턴스의 소속되어 있기 때문에, 인스턴스가 해제된다면 같이 - 1 을 하게 됨. 하지만, 만약 프로퍼티의 레퍼런스 카운터가 1 이상이라면 속한 클래스 인스턴스가 해제된다고 해도 프로퍼티의 인스턴스는 메모리에 남아있다. 즉, 무조건 속한 클래스의 인스턴스가 사라진다고 프로퍼티의 레퍼런스도 같이 사라진다는 것이 아니라 단순 reference count - 1을 해주는 것.
-
-
+) 앞서 ARC설명 시에 ARC가 compile time에 실행되어 동적으로 실행되는 것들의 reference count를 세고 메모리 관리를 할 수있다고 하는데 그게 가능한 이유가 뭘까?
답은 ARC의 작동방식과 연관이 있다. ARC의 메커니즘은 Swift Runtime이라는 라이브러리에 구현되어 있다. 그리고 Swift Runtime은 HeapObject 구조체를 사용하여 동적으로 할당된 모든 개체를 나타낸다. 이때 여기서 the SIL generation phase 라는 컴파일 단계가 실행되게 된다. 그리고서는 HeapObject의 initialization & destruction 을 가로채어 swiftc 컴파일러가 swift_retain() & swift_release() 코드를 적절히 삽입해주고 이 삽입된 코드들이 런타임에 적절히 실행되는 것이다.
+) REF
https://developer.apple.com/videos/play/wwdc2021/10216/https://velog.io/@ellyheetov/ARC-VS-GC
https://sujinnaljin.medium.com/ios-arc-%EB%BF%8C%EC%8B%9C%EA%B8%B0-9b3e5dc23814
https://www.vadimbulavin.com/swift-memory-management-arc-strong-weak-and-unowned/
-