Cocoa Internals - 객체

객체

글쓴이는 일반적으로 객체 지향 프로그래밍Object-Oriented Programming이라고 불리는 개념의 객체 단위 추상화의 중요성을 강조하기 위해 ‘객체 중심 프로그래밍’이라는 용어를 사용하고 있다.

객체는 객체 인스턴스와 이를 만들기 위한 클래스 그 자체를 포함하는 개념이다.

클래스와 객체 인스턴스

절차 중심 프로그래밍과는 다르게, 객체 중심 프로그래밍은 객체 안에 프로퍼티와 메소드의 형태로 변수와 함수를 구현하고, 프로그램을 구성하는 객체들끼리 메세지를 주고받아 상호 협력하는 형태로 동작한다.

  • 클래스 : 개발자가 문제 해결을 위해 추상화하여 만드는 코드
  • 객체 : 클래스가 메모리에 구체화된 실체. 인스턴스. 하나의 클래스 코드에서 여러 개의 객체가 생성될 수 있다.

객체에 대한 철학

서양 철학에서, 객체object라는 단어는 나를 중심으로 세상을 바라봤을 때 세상의 모든 것을 지칭한다.

이처럼 자연에 실존하는 객체를 그에 대응하는 형체가 없는 개념과 언어로 추상화시켜 생각하는 방식을 프로그래밍에 도입한 것이 객체 중심 프로그래밍이다.

실생활의 객체들을 책임과 역할에 따라 구분하고, 객체 사이의 관계와 연결하도록 생각하는 방식은 객체 중심 프로그래밍 패러다임을 이루는 골격이다.

추상화 구체화
클래스 객체
개념 구체
일반화 개별화
무형 유형
추상화 구상화
이론 실제

객체 중심 프로그래밍

랑그

langue

같은 언어를 사용하는 사람들끼리 생각하는 방식에 대한 원칙.

프로그래밍에 도입하여 생각해봤을 때, 어떠한 알고리즘(개념)이라고 말할 수 있음.

파롤

parole

실제 대화나 상황에 따라서 표현이나 발음이 달라지는 것.

프로그래밍에 도입하여 생각해봤을 때, 어떠한 알고리즘의 각기 다른 구현이라고 말할 수 있음.


SOLID 법칙은 프로그램을 객체 중심으로 설계하기 위한 다섯 가지 원칙을 제시한다.

(단일 책임 / 개방-폐쇄 / 리스코프 치환 / 인터페이스 분리 / 의존성 역전)

이를 활용하면 객체가 어떻게 동작할지 잘 추상화하여, 명확하고 깔끔한 책임 구조, 높은 응집력과 낮은 의존성, 변화에 대한 유용성 등에서 이점을 얻을 수 있다.

Objective-C 객체

Objective-C 기초 문법

  • +로 시작하는 메소드는 클래스 메소드
  • -로 시작하는 메소드는 인스턴스 메소드

클래스 명세와 객체 인스턴스

메모리에 구체화된 객체 인스턴스는 해당 클래스 코드를 공유하며, 다른 인스턴스와는 구분되는 고유한 데이터를 가진다.

이처럼 클래스로 작성한 코드는 모든 객체 인스턴스가 공유하며, 클래스 명세를 기준으로 만들어진 각각의 객체 인스턴스가 클래스 코드를 재사용한다.

Objective-C 2.0 이후 변화

  • objc_object 구조체는 객체에 대한 타입이다.
    • Objective-C 2.0부터 인스턴스의 클래스를 가리키는 isa 포인터에 직접 접근할 수 없다.
    • NSObject-(Class)class에 메세지를 보내거나 object_getClass() 런타임 API를 사용하여 객체의 클래스를 알아낼 수 있다.
  • id 는 객체를 가리키는 포인터 타입이다.
  • Class 는 Objective-C 클래스를 표현하는 불투명 타입이다.

Swift 네이티브 객체

Swift 중간 언어Swift Intermediate Langugae를 살펴본다.

Swift 컴파일러 swiftc는 Swift 언어로 작성한 코드 파일을 읽어서 SIL을 만들고 LLVM IR로 변환하며, LLVM IR 최적화기를 거쳐 최종적으로 타겟 머신에 맞는 기계 코드를 생성한다.

Swift 네이티브 객체는 Objective-C와의 호환을 위해 Objective-C 객체와 비슷한 모양으로 만들어지는 것을 확인할 수 있다.

Swift의 클래스 타입은 NSObject를 상속받지 않는 경우 Objective-C와 호환되지 않는 네이티브 객체가 된다.

글쓴이는 독자의 이해를 돕기 위해 SIL 중 일부 코드를 수정하거나 제거하였다. 실제 SIL 표현과는 다를 수 있다.

다음의 코드에 대한 SIL을 살펴본다.

class NativePen {
let id = 512
var sequence = 1024
}

Swift 네이티브 객체의 생성 및 소멸 함수

allocating_init()init(), deallocating_deinit()deinit() 함수가 만들어진다.

특히 Objective-C에서는 alloc 과정 후 init 과정을 나누어서 해야 하는데, Swift에서는 allocating_init() 함수 내부에서 init()을 호출하여 초기화하는 것을 확인할 수 있다.

deallocation_deinit() 함수는 self 객체 레퍼런스를 deinit() 함수에 넘겨서 힙에 만들어진 레퍼런스 변수가 있다면 소멸시키는 과정을 거친다.

// deallocating_deinit 간단 정리
@<변형된_이름> : $@convention(method) (@owned NativePen) -> () {
%0 : $NativePen, let, name "self", argno 1 // 숨겨진 첫 번째 인자는 `self`.
%3 = class.NativePen.deinit($0) // `self`에 대한 `deinit()` 호출.
%4 = unchecked_ref_cast(%3)
dealloc_ref(%4) // 자기 자신을 메모리에서 해제.
}

// deinit 간단 정리
@<변형된_이름> : $@convention(method) (@guaranteed NativePen) -> @owned Buildin.NativeObject {
%0 : $NativePen, let, name "self", argno 1 // 숨겨진 첫 번째 인자는 `self`.
%2 = unchecked_ref_cast($0)
return %2
}

// init 간단 정리
@<변형된_이름> : $@convention(method) (@owned NativePen) -> @owned NativePen {
%0 : $NativePen, let, name "self"
%2 : mark_uninitialized([rootself], %0)

// `let id = 512` 초기화 과정.
// Int 타입 / 정수 리터럴로 작성된 512의 정보를 사용하여 Int 타입의 인스턴스 생성.
%4 = metatype(Int.Type)
%5 = integer_literal($Builtin.Int2048, 512)
%6 = Swift.Int.init(%5, %4)
%7 = ref_element_addr(%2 : $NativePen, #NativePen.id)
assign(%6 to %7)

// `var sequence = 1024` 초기화 과정.
// Int 타입 / 정수 리터럴로 작성된 1024의 정보를 사용하여 Int 타입의 인스턴스 생성.
%10 = metatype(Int.Type)
%11 = integer.literal($Builtin.Int2048, 1024)
%12 = Swift.Int.init(%11, %10)
%13 = ref_element_addr(%2 : $NativePen, #NativePen.sequence)
assign(%12 to %13)

return %2
}

// allocating_init 간단 정리
@<변형된_이름> : $@convention(thin) (@thick NativePen.Type) -> @owned NativePen {
%1 = alloc_ref($NativePen) // 객체 생성을 위한 메모리 공간 할당.
%3 = class.NativePen.init(%1) // 할당된 메모리 공간에 대하여 `init()` 호출.
return %3
}

Swift 네이티브 객체의 getter 및 setter 함수

Swift 클래스의 프로퍼티에 대하여 getter 함수와 setter 함수가 만들어진다.

idlet으로 선언되었으므로 setter가 만들어지지 않는다.

// id.getter 간단 정리
@<변형된_이름> : $@convention(method) (@guaranteed NativePen) -> Int {
%0 : $NativePen, let, name "self", argno 1 // 숨겨진 첫 번째 인자 `self`.
%2 = ref_element_addr(%0 : $NativePen, #NativePen.id) // `self`로부터 `NativePen.id` 참조.
%3 = load(%2) // 참조된 `NativePen.id`로부터 값 복사.
return %3
}

// sequence.getter 간단 정리
@<변형된_이름> : $@convention(method) (@guaranteed NativePen) -> Int {
%0 : $NativePen, let, name "self", argno 1 // 숨겨진 첫 번째 인자 `self`.
%2 = ref_element_addr(%0 : $NativePen, $NativePen.sequence) // `self`로부터 `NativePen.sequence` 참조.
%3 = load(%2) // 참조된 `NativePen.sequence`로부터 값 복사.
return %3
}

// sequence.setter 간단 정리
@<변형된_이름> : $@convention(method) (Int, @guaranteed NativePen) -> () {
%0 : $Int, let, name "value", argno 1 // 첫 번째 인자로 들어간 할당될 값
%1 : $NativePen, let, name "self", argno 2 // 두 번째 인자로 들어간 `self`.
%4 = ref_element_addr(%1 : $NativePen, #NativePen.sequence) // `self`로부터 `NativePen.sequence` 참조.
assign(%0 to %4) // `NativePen.sequence` 참조에 할당될 값(%0) 할당.
}

Swift - Objective-C 호환 객체

Objective-C 코드에서 Swift 객체를 사용하려면 해당 Swift 객체는 반드시 NSObject를 최상위 클래스로 지정하여 상속받아 만들어져야 한다.

Objective-C와의 호환을 위해 중간 코드에 변형이 생긴다. NSObject의 메소드를 호출하기도 하며, 레퍼런스 카운트를 관리하는 코드도 추가된다.

요약

디자인 패턴이나 객체 중심 디자인 원칙은 객체 중심으로 생각하는 과정을 반복적으로 경험하고 분석하여 규칙으로 만들어 놓은 것이다.

객체는 객관적이어야 한다. 객체를 표현한 코드는 누군가와 생각을 공유하기 위한 언어적 표현일 뿐이다.

Swift 네이티브 객체는 Objective-C 호환 객체와는 다르게 참조 계산 함수를 다루지 않고 부가적인 함수도 만들지 않는다.

Cocoa 프레임워크는 Swift 네이티브 객체를 사용하도록 포팅되고 있다.

객체 정체성과 등가성

Objective-C 객체와 메모리 구조

낮은 메모리 주소부터 차례대로,

  • 텍스트 영역 (TEXT segment)
    • 프로그램 코드 포함
  • 데이터 영역 (DATA Segment)
    • 고정 값이 정해진 전역 변수 포함
  • 심벌 영역 (BSS Segment)
    • 초기 값을 0으로 할당하는 전역 변수 포함
  • 힙 영역 (HEAP Segment)
    • 낮은 주소에서 높은 주소로 메모리 적재
    • 객체 포함
  • 스택 영역 (Stack Segment)
    • 높은 주소에서 낮은 주소로 (반대 방향으로) 메모리 적재
    • 힙에 담긴 메모리 주소를 참조하는 포인터 포함

레퍼런스 카운트를 활용하여 객체 포인터가 유효하도록 보호한다.

Pen *pen = [[Pen alloc] init];

위와 같은 코드로 Pen 클래스의 객체를 생성했다고 할 때,

  • 포인터 변수 pen은 스택 영역에 만들어진다.
  • 포인터 변수가 가리키는 객체는 힙 영역에 만들어진다.
  • 객체에 대한 클래스 자체는 프로그램이 로딩될 때 미리 힙 영역에 로드된다.
  • 클래스의 메소드는 클래스명과 메소드명이 합쳐진 어떠한 형태를 가진 셀렉터(Selector)로 매핑되며, 구현부는 텍스트 영역에 기계어로 저장된다.

객체 정체성

정체성 : identity

Pen *aPen = [Pen new];
Pen *bPen = [Pen new];

위와 같은 코드로 aPen 객체와 bPen 객체를 만들었을 때, 이 둘은 다른 정체성을 갖게 된다.

각각의 객체는 힙 영역의 각기 다른 메모리에 위치해 있기 때문이다.

bPen = aPen 과 같은 코드를 작성하면 bPen 포인터는 aPen이 가리키고 있던 객체를 가리키게 되며, 그러므로 aPenbPen은 같은 정체성을 갖게 된다.

객체 등가성

등가성 : equality

어떠한 두 개의 포인터 변수가 같은 클래스로부터 나온 각기 다른 객체를 가리키지만, 각각의 객체가 그 속성에 대하여 서로 같은 값을 가지고 있다면 등가성이 성립된다.

등가성을 비교하기 위해서는 == 연산자 대신 -isEqual: 메소드를 재정의하여 사용한다.

객체 예외성

모든 Cocoa 객체가 힙 영역에 생성되는 것은 아니다.

NSString* aPenName = @"BluePen";
NSString* bPenName = @"BluePen";

NSString 클래스의 경우 NSObject를 상속받는 Cocoa 클래스 중 유일하게 전역 변수로 선언될 수 있다.

“BluePen”이라는 문자열 리터럴 값은 텍스트 영역에 저장되며, aPenName 변수는 전역 변수 형태로 데이터 영역에 만들어진다.

bPenNameaPenName이 가리키는 문자열 리터럴을 공유하게 되어 같은 텍스트 영역을 사용하고 해당 객체는 전역 변수 형태로 할당된다.

결과적으로 두 개의 변수는 스택 영역의 각기 다른 주소에 저장되나, 이들이 가리키는 객체 및 사용하는 문자열 리터럴은 같은 것들을 가리키게 된다.

그러므로 aPenNamebPenName는 동일한 정체성을 갖게 된다. (문자열 인터닝)

Swift의 문자열

Swift의 String 구조체 인스턴스는 네이티브 문자열 객체를 만들 수도 있고, NSString을 연결해서 사용할 수도 있다.

네이티브 문자열 객체의 경우 텍스트 영역에 있는 문자열을 OpaquePointer 형태의 포인터로 그대로 연결하는 방식을 사용한다.

-hash 메소드

NSDictionary와 같은 컬렉션 객체는 -hash 메소드를 사용하여 해시 값을 비교하므로 -isEqual 메소드를 재정의한 경우 -hash 메소드도 재정의해야 할 필요가 있다.

기본적으로 NSObject-hash 메소드는 메모리 포인터 값을 NSUInteger 타입의 숫자로 바꾸는 역할을 한다.

Swift의 Hashable 프로토콜

해시 가능한 타입은 Hashable 프로토콜을 준수하며, 이는 hashValue() 메소드와 ==() 메소드를 요구한다.

요약

객체는 메모리에 할당되면 고유한 정체성을 갖게 된다. 그러나 고유한 객체 중 모든 속성이 같은 객체도 존재할 수 있다.

등가성이 성립하는지 확인하기 위해서는 -isEqual:를 재정의하여 사용할 필요가 있다.

-hash 메소드도 구현해야 해시 값을 활용하는 NSDictionary와 같은 컬렉션 객체 내부에서 비교가 가능해진다.

객체 사이 관계

메타 클래스

객체 인스턴스의 isa 포인터는 클래스를 가리킨다. 클래스의 isa 포인터는 메타 클래스를 가리킨다.

클래스는 인스턴스 메소드 목록과 코드를 갖는다. 메타 클래스는 클래스 메소드 목록과 코드를 갖는다.

상속

객체 중심 프로그래밍 언어가 갖는 특징

  • 추상화한 클래스 명세
  • 객체 인스턴스의 활용
  • 캡슐화
  • 상속
  • 다형성

이 중 상속은 객체 사이의 관계와 밀접한 연관이 있다.

Objective-C의 객체를 나타내는 objc_object 구조체는 클래스에 대한 메타 클래스를 연결하는 isa 포인터와 상속받은 상위 클래스를 가리키는 super_class 포인터를 포함한다. 최상위 클래스의 경우 super 포인터는 nil 값을 갖는다.

서브클래스의 super는 상위 클래스를 가리킨다. 서브클래스의 메타 클래스의 super는 상위 클래스의 메타 클래스를 가리킨다.

is-a 관계와 has-a 관계

일반적으로 객체 중심 언어에서 is-a 관계는 객체 인스턴스와 클래스의 관계 또는 서브 클래스와 수퍼 클래스의 상속 관계를 나타낸다.

Objective-C에서 객체 인스턴스와 클래스의 관계는 isa로, 서브 클래스와 수퍼 클래스의 관계는 super로 나타낸다.

has-a 관계는 강한 참조 결합성을 갖는 구성 관계와, 약한 참조 결합성을 갖는 집합 관계로 구분한다.

  • 구성 관계의 경우 참조하는 객체와 하위 객체가 동일한 생명 주기를 갖는다.
    • ARC의 strong 개념과 동일
  • 집합 관계의 경우 참조하는 객체가 사라지더라도 하위 객체는 사라지지 않는다.
    • ARC의 weak 개념과 동일

요약

상속 관계는 객체 사이의 관계를 바꾸기 위한 유지보수나 리팩토링이 어려운 밀결합 형태이다.

이를 해결하기 위해 Objective-C에서는 카테고리를 사용하여 객체를 확장하는 것을 권장한다.

Objective-C 런타임

클래스와 메타 클래스를 메모리 로드하는 것과 같은 역할은 Objective-C 런타임이 담당한다.

런타임은 실행 중에 객체에게 보내는 메세지를 처리할 메소드를 찾거나, 객체 메모리 관리 및 동적 타입 변환 등을 처리하는 C 함수 라이브러리다.

기존 런타임과 최신 런타임

32bit OS X에서 동작하는 런타임 라이브러리를 기존 런타임legacy runtime이라고 부른다.

OS X 10.5 이후 및 iOS에서 동작하는 런타임을 최신 런타임modern runtime이라고 부른다.

최신 런타임에는 Objective-C에 추가된 프로퍼티나 빠른 탐색, ARC, 블록 기능을 위한 개선이 이루어졌다.

메세지 디스패치

Objective-C는 객체의 메소드를 직접 호출하지 않고, 객체에 메세지를 보내는 방식으로 동작한다.

객체에 메세지를 보내는 과정

  1. 클래스에 메소드를 선언할 때는 리턴 값 / 메소드 이름 / 인자 값 타입 및 변수명을 순서대로 명시한다.
- (void) replacePen:(Pen *) pen1 withPen:(Pen*) pen2
  1. 객체 인스턴스의 메소드를 호출하기 위해 다음과 같이 인스턴스에 메세지를 보낸다.
[aPenHolder replacePen:aPen withPen:bPen];
  1. 컴파일러는 위의 코드를 보고 메소드의 이름을 replacePen:withPen:이라고 인식한다. 그러므로 컴파일 과정에서 objc_msgSend() 런타임 API를 사용하는 코드로 대체한다.
objc_msgSend(aPenHolder, @selector(replacePen:withPen:), aPen, bPen);
  1. 실행 중에 Objective-C 런타임은 objc_msgSend()를 실행하면서 메세지로 어떠한 메소드를 실행할지 메세지 디스패치 과정을 통해 찾아낸다.
    • 수신 객체의 클래스 타입(objc_msgSend()의 첫 번째 인자)과 메세지 셀렉터(objc_msgSend()의 두 번째 인자)를 기준으로 어떤 메소드를 실행할지 선택한다.
    • 상속 관계가 있는 경우 다형성을 따라 상위 클래스의 메소드를 선택할 수 있다.
  2. 어떤 메소드를 실행할지 셀렉터를 고른 후, 해당 객체의 메소드 구현부 메모리 주소를 확인하여 메소드를 호출할 준비를 끝낸다.
    • IMP(aPenHolder, @selector(replacePen:withPen:), aPen, bPen)과 같은 형태로 구현부 메모리 주소를 얻는다.
    • 메모리 주소는 내부 캐시에 저장되고 이후에는 캐시에서 먼저 찾는다.
    • 해시 값으로 캐시를 확인하며, 데이터가 없거나 메모리 주소가 바뀌면 새로 업데이트된다.

최신 런타임 API

  • 클래스 관리 API : 클래스 이름이나 수퍼 클래스, 인스턴스 변수를 찾거나 프로퍼티나 프로토콜을 추가하거나 레이아웃 조정
  • 클래스 추가 API : 실행 중에 클래스와 메타 클래스를 생성하고 등록, 메모리에 로드한 클래스와 메타 클래스 파괴
  • 객체 인스턴스 생성 API : 객체 인스턴스를 메모리에 생성하거나 해제
  • 객체 인스턴스 관리 API : 객체 인스턴스에 대한 인스턴스 변수를 관리하거나 클래스 타입 검색
  • 클래스 메타 정보 API : 클래스 메타 정보를 기준으로 등록된 클래스 목록과 클래스 타입이나 메타 클래스 정보 검색
  • 인스턴스 변수 관리 API : 인스턴스 변수 타입에 대한 변수 이름, 타입 이름, 오프셋 관리
  • 연관 참조 API : 객체에 연관된 정보 관리
  • 메세지 전송 API : 객체에 메세지 전송
  • 메소드 관리 API : 메소드 타입에 대한 호출, 구현부 관리, 셀렉터 검색, 구현부 검색
  • 라이브러리 관리 API : 프레임워크나 동적 라이브러리 정보 확인
  • 셀렉터 관리 API : 셀렉터 타입과 관련된 정보 관리
  • 프로토콜 관리 API : 프로토콜 타입과 프로토콜 인스턴스 관리
  • 프로퍼티 관리 API : 프로퍼티와 프로퍼티 속성 관리
  • 언어 기능 API : 반복문이나 블록, 약한 참조 등의 언어 기능 관련

요약

Objective-C 런타임이 제공하는 API와 실행 중에 객체와 메소드, 블록 같은 언어 기능을 처리하는 방식을 알아두면 내부 동작을 이해하는 데 도움을 줄 것이다.

런타임 API를 사용하면 실행 중에 클래스나 객체의 구조나 함수를 바꾸는 동작이 가능하다. (리플렉션)