[Objective-C] 메시지로부터 메소드가 호출되는 과정

objc_msgSend 함수와 메시징 매커니즘

objective-c에서는 객체의 메소드를 호출하는 것을 객체에게 메시지를 보낸다라고 표현한다. 이는 메소드 자체를 객체화하는 디자인 패턴과 쉽게 익숙해질 수 있게 하려는 포석이기보다는 실제로 메소드의 호출이 메시지를 보내는 형태로 구현이 되어있기 때문이다. objective-c의 이러한 메시징 매커니즘을 이해하려면 몇 가지 깊숙한 곳의 내용을 찬찬히 들여다 볼 필요가 있다.

셀렉터

객체에 정의된 모든 메소드는 컴파일러에 의해 내부적으로 숫자값을 가진 변수로 관리된다. 이를 셀렉터라고한다.(각각의 셀렉터는 정수 값임) 셀렉터는 다시 클래스 내에서 특별한 테이블에 셀렉터값과 이 셀렉터가 참조하는 프로시저의 주소값을 맵핑한 형태로 기록된다. 또한 컴파일러는 메소드들을 셀렉터로 인코딩하는 동시에 메소드 자체를 함수 프로시저로 변경하여 컴파일하게 된다. 각각의 셀렉터는 원래의 메소드가 변형되어 생성된 프로시저의 포인터를 참조한다.

메시지 수신을 함수 호출로 바꾸는 objc_msgSend

오브젝티브 C의 메소드 호출은 “메시지”라고 표현되며 다음과 같은 형식으로 쓴다.

[receiver message];

이 표현은 컴파일러에 의해 함수 호출 문법으로 변경되는데 이는 다음과 같은 포맷이다.

objc_msgSend(receiver, selector);

만약 이 메소드가 파라미터를 받는다면 다음과 같은 형태로 호출이 일어난다.

objc_msgSend(receiver, selector, argv1, argv2, ...);

런타임은 객체에 메시지가 보내질 때 이 함수를 호출한다. 수신자(receiver)는 메시지를 받는 객체가 되고, 두 번째 인자는 호출할 셀렉터가 된다. 이 함수는 뒤에서 어떤 일련의 과정을 거쳐서 호출해야할 함수 프로시저(컴파일러가 메소드를 변형하여 생성한)를 찾고 여기에 그 인자들을 고스란히 전달하여 호출한다.

클래스 속에 숨은 비밀, dispatch table

모든 Objective-C 클래스에는 숨겨진 정보들이 몇 가지 존재한다. 이러한 정보 중 가장 중요한 두 가지 요소는 수퍼 클래스에 대한 포인터 값과 dispatch table이다.

여기서 이야기하려는 부분은 바로 이 dispatch table인데 여기에 바로 객체(정확히는 클래스에)에 정의된 메소드들의 셀렉터와 이 셀렉터가 참조하고 있는 함수 프로시저의 주소가 짝지어져 기록되어 있다.

objc_msgSend 함수는 첫번째 인자로 받은 “수신객체”의 isa값을 통해 객체의 클래스를 참조해서 이 클래스의 dispatch table에서 두 번째 인자로 넘어온 셀렉터를 찾는다. 셀렉터가 발견되면 이 셀렉터와 짝을 이룬 함수 프로시저의 번지를 알게되고 여기에 인자들을 전달해 호출한다. (이 이후의 과정은 C와 동일하다. 스택영역에 해당 함수에 정의된 인자와 지역변수들이 복사되고 함수 코드가 돌아간다)

만약 dispatch table에 찾고자하는 셀렉터가 없다면 objc_msgSend함수는 이 클래스의 수퍼클래스에 대한 참조를 따라 수퍼클래스에서 다시 dispatch table을 검사한다. 이런식으로 계속 타고 올라가다가 최상위 객체인 NSObject에 다다른 후에도 호출하고자 하는 셀렉터를 dispatch table에서 찾지 못하면 런타임은 수신객체가 해당 셀렉터를 처리할 수 없다고 판단, 후속 처리를 시작한다.(후속 처리라고 표현하는 이유는 바로 예외를 발생시키지 않고 수신객체에게 처리할 수 있는 기회를 다시 한 번 준다. 이것이 forward이다.)

이런 방식으로 ObjectiveC의 메소드 호출은 실행시간에 호출해야하는 프로시저가 동적으로 결정된다. 이를 좀 전문적인 용어로 표현하면 “메소드가 메시지에 동적으로 바인딩된다”고 할 수 있다.

이러한 간접적인 함수호출은 아무래도 C에서의 직접적인 함수호출보다 비용이 많이 든다. 그러니까 돈을 내는 게 아니라 시간이 많이 걸리게 된다. 하지만 이 차이는 실제로는 아주 근소하다. 또한 런타임은 객체가 한 번 호출한 프로시저는 높은 확률로 다시 호출한다고 보고 한 번 호출했던 프로시저의 포인터는 모두 캐싱한다. 이 캐시의 양은 앱이 시작된 후부터 동적으로 증가하여 이내 충분한 양으로 축적된다.

숨은 인자값 사용하기

objc_msgSend함수의 첫 두 인자값은 사실 숨겨진 형태로 메소드 내부에서 참조가 가능하며 때에 따라 유용하게 쓰일 수 있다.

  • receiver
  • selector

그 중 첫번째 인자인 “메시지를 받는 수신 객체”는 매우 중요하고, 유용하며 실제로도 많이 쓰인다. 메소드 내에서 메시지를 받는 객체를 지칭하는 키워드, 바로 self이다. 두 번째로 현재 호출된 메소드를 가리키는 변수는 _cmd라고 한다. 이 변수는 SEL타입으로 현재 실행중인 메소드의 셀렉터를 저장하고 있다.

다음 이상한(?) 코드는 이 두 변수를 사용해서 어떤 작업을 처리한다.

-(id)strange {
  id target = getTarget();
  SEL selector = getSelector();
  if( target == self || selector == _cmd) return nil;
  else return [target performSelector:selector];
}

프로시저의 주소 구하기

동적 바인딩의 오버헤드를 줄여서 메소드를 호출하는 방법은 해당 메소드의 프로시저 주소를 구해서 그 프로시저를 바로 호출하는 것이다.

C에서 배열의 이름 자체가 배열의 시작 번지를 표시하는 포인터로 간주되듯, 함수의 이름은 함수의 주소를 나타내는 포인터가 된다. 컴파일러는 함수의 주소 뒤에 (…) 가 붙으면 이 주소의 함수를 호출하게 된다.

NSObject-methodForSelector:는 주어진 셀렉터에 대해 대응하는 객체의 메소드가 참조하는 프로시저의 포인터(그렇다 함수 포인터다)를 반환한다. 단순히 이 포인터를 호출하기만 하면 되는데, 여기서 유의해야 할 점은 포인터 변수의 타입을 선언할 때 세심하게 선언해야 한다는 것이다. 함수의 라턴 타입과 파라미터 타입을 정확히 선언해야 하며, 첫번째와 두번째 파라미터는 수신 객체와 셀렉터가 되어야 한다.

프로시저의 직접호출이 동적바인딩의 오버헤드를 줄일 수는 있으나 아주 특별한 경우에만 쓸 일이 있다. 동일한 프로시저를 루프 내에서 반복적으로 상당히 많이 호출해야 할 경우 정도에 성능의 향상 효과를 볼 수 있는 수준일 것이다.

다음 코드는 어떤 클래스의 -setFilled: 메소드를 1000번 호출해야 하기 위해 프로시저를 직접 호출하는 예이다.

/* targetList는 target 객체들의 배열로 간주한다.*/

id target = targetList[0];
void (*procToExec)(id, SEL, BOOL);
int i;
procToExec = (void (*)(id, SEL, BOOL))[target methodForSelector:@selector(setFilled:)];
for(i=0;i<1000;i++) {
  procToExec(targetList[i], @selector(setFilled:), YES);
}