« Previous : 1 : 2 : 3 : 4 : 5 : ... 8 : Next »

C 언어의 애환

1. 비트필드

C언어의 구조체에는 다른 언어에서는 거의 찾을 수 없는 비트필드라는 물건이 있다.
얘는 굉장히 편리하고 강력한 프로그래밍 요소이다. 바이트 경계에 딱 떨어지지 않는 숫자를 일반 숫자 다루듯이 읽고 쓰게 해 주니 이 얼마나 대단한가? IEEE754 부동소수점이라든가, 과거 2바이트 조합형 한글 같은 건 비트필드 구조체를 잘 만들어서 내부 구조를 쉽게 분석해 볼 수 있다.

다만, 비트필드와 관련해서 언어 문법 차원에서 다음과 같은 점이 보완되거나 강화됐으면 좋겠다는 생각이 개인적으로 오래 전부터 들었다.

(1) 지정 가능한 자료형은 그냥 unsigned 아니면 signed 둘 중 하나로 굳혀 버리고, 나머지 쓰잘데기없는 키워드들은 몽땅 거부하고 에러 처리했으면 좋겠다. 어차피 이 필드의 크기는 뒤의 비트수에 의해서 결정될 텐데.. int니 char이니 long이니 하는 건 전혀 불필요하고 쓸데없는 정보이기 때문이다. 괜히 unsigned char _field: 10; 이런 거 체크해서 10이 8보다 더 클 때만 에러 처리하는 건 잉여스러운 짓이다.

사실 본인은 비트필드에서 부호 "있는" 자료형이 쓰이기는 하는지, signed조차도 필요는 한지 그것도 굉장히 회의적이다. 차라리 enum이 쓰일 가능성은 있을지 모르겠다.

(2) 비트필드에서 공간을 배치하는 순서는 결국 타겟 플랫폼의 비트 endianness의 영향을 받는다. unsigned member : 4 라고 해 주면.. little endian에서는 하위 0~3비트가 할당되며, big endian에서는 상위 4~7비트가 할당된다.
더구나 비트필드라는 건 결국 2~4바이트짜리 커다란 정수 하나를 잘게 쪼개기 위해 존재하는 물건인데, 쪼개는 순서 자체가 비트 endianness에 따라 달라진다.

결국 비트필드를 사용해서 특정 파일 포맷이나 패킷 구조를 기술해 놓은 구조체 선언을 보면.. 빌드 환경의 endianness에 따라 조건부 컴파일을 시켜서 little일 때는 같은 멤버를 abcd 순으로 배치하고, big일 때는 이를 dcba 순으로 무식하게 배열해 놓곤 한다.

이게 정형화된 패턴이니 프로그래머가 쓸데없는 삽질을 할 필요 없이, 언어 차원에서 문법을 지원을 좀 했으면 좋겠다.
"이 비트필드들은 16/32비트 기준으로 큰/작은 자리부터 순서대로 분해하는 것이다. 그러니 타겟 아키텍처의 endianness가 이와 정반대이면 컴파일러가 알아서 멤버들의 배치 순서를 뒤집어라" 이렇게 힌트를 준다.

이런 일이 컴파일러가 하기에는 너무 지저분하다면 #pragma 같은 걸로 빼내서 전처리기 계층에다 담당시켜도 된다.
핵심 요지는.. 똑같은 멤버를 프로그래머가 순서만 바꿔서 다시 써 주고 조건부 컴파일을 시키는 무식한 짓만은 좀 없어져야 한다는 것이다.

비트필드가 쓰일 정도의 상황이면.. 아마 이 공간 전체를 거대한 숫자 한 덩어리로 같이 취급하게도 해 주는 union, 그리고 구조체 멤버 배치를 어느 플랫폼에서나 비트 단위로 일치하게 강제 동기화시키는 #pragma pack도 같이 쓰이고 있을 가능성이 매우 높다.
#pragma pack과 #pragma once는 진짜로 사실상의 표준이니 C/C++에서 정식 표준으로 좀 등재시켜야 하지 싶다. char32_t / char16_t 같은 게 결국 built-in type으로 받아들여지고 정식 표준이 된 것처럼 말이다.

참, 당연한 얘기이다만.. 구조체 템플릿에서는 비트필드의 크기를 나타내는 숫자도 템플릿 인자로 공급해 줄 수 있다.
비트필드의 크기는 구조체 멤버에 들어있는 배열의 크기와 위상이 거의 같으니 말이다. 구조체의 크기에 영향을 주는 숫자이며 컴파일 시점에서 값이 상수로 결정되어야 한다.

template<size_t N> struct XXXX {
    unsigned _member: N;
};

아주 C스러운 요소와 C++스러운 요소가 한데 만난 것 같다. ㄲㄲㄲㄲㄲ 비트필드의 크기를 템플릿 인자로 지정할 일은 극히 드물 것이다.;;

교통 분야에서 좌측· 우측 통행이 국가별로 찢어져 있다면, 디지털 컴퓨터에서는 비트의 배치 순서 endianness가 통행 방향과 비슷한 개념이며 아키텍처별로 찢어져 있는 듯하다.
네트워크 표준은 big endian이지만, 컴퓨터들은 x86이 주류이다 보니 little endian이 주류이다. 이건 세계적으로 자동차 도로 우측 vs 좌측과 비슷한 비율이며, 안드로이드 vs iOS와 비슷한 비율인 것 같다. 본인은 big endian을 native로 사용하는 컴퓨터를 평생 한 번도 구경해 본 적이 없다.

2. C의 단순 평면성

C++에 비해, C는 마소에서 거의 아오안 취급을 하기 때문에 컴파일러의 버전이 바뀌어도 달라지는 게 거의 없다. 다만..

  • C99에서 추가된 가변 길이 배열이 Visual C++에서는 지원되지 않는다.
  • 구조체의 가장 마지막 멤버를 구조체 자체의 크기를 차지하지 않는 명목상의 멤버로.. char data[] 내지 data[0] 같은 형태로 선언해서 구조체의 뒷부분을 가변 길이로 활용하는 게.. 여전히 일부 컴파일러의 편법일 뿐, 정식 표준이 아닌 것 같다.
  • 대소문자를 무시하고 문자열을 비교하는 함수가 의외로 표준이 아닌 것 같다. stricmp와 strcasecmp 부류가 혼재해 있다. C는 라이브러리 함수가 ANSI니 POSIX니 하면서 의외로 파편화된 게 좀 있어서 플랫폼 간의 이식성을 저해하는 중이다.

C는 클래스와 상속 계층이 없을 뿐만 아니라, 각종 명칭에 다단계 계층 scope이란 것도 없다. namespace나 using 같은 걸 신경쓸 필요 없이 모든 명칭이 오로지 local 아니면 global.. 그도 아니면 매크로 함수밖에 선택의 여지가 없다. 클래스라기보다는 번역 단위 자체가 클래스와 비슷하며 static이 외부로 노출되지 않는 private 역할을 얼추 담당한다.

그러니 뭔가 아주 단순하며, 입체적인 게 아니라 '평면적이고' 깔끔해 보이기는 하는데.. 한편으로 너무 중구난방이고 명칭이 충돌하기 쉽다.
새로 짓는 이름은 접두사에 목숨을 걸어야 할 것 같다. 이런 언어로 초대형 라이브러리를 만들고 대형 프로그램을 관리하는 데는 한계가 있을 수밖에 없다.

또한 매크로 함수를 너무 사악하게 남발 남용할 경우, 어지간히 복잡하게 꼬인 C++ 템플릿 이상으로 코드가 알아보기 어려워진다. 특히 전처리기의 존재를 알지 못하는 디버거는 매크로 함수와 완전히 상극이다.

매크로 함수 내부의 코드를 한 단계씩 실행할 수 없고, 또 ## 연산자에 의해 새로 생긴 토큰 명칭들은 어지간한 IDE에서 자동으로 파악도 못 해 준다. 이렇게 IDE와의 괴리가 커지고 붕 떠 버린 코드는 사람 입장에서도 짜증이 나서 제대로 들여다보고 유지 보수하기가 싫어진다. 이는 결국 생산성의 저하로 이어진다.
이런 게 C의 어쩔 수 없는 한계인 것 같다. -_-

3. C언어의 강력하고 자유로운 면모

  • 지역 변수, 전역 변수, heap 등 어디든지 가리킬 수 있는 포인터
  • 한 함수 안에서 어디로든 분기할 수 있는 goto문
  • type이고 뭐고 다 씹어먹고서 메모리를 조작할 수 있는 memcpy, memmove (malloc, free 같은 생짜 수동 메모리 관리는 덤)
  • 무슨 토큰이건 다 치환할 수 있는 전처리기 매크로

하지만 위의 요소들은 위험성과 복잡도도 너무 키운다. 저런 저수준 조작이 잔뜩 쓰인 복잡한 코드에서 버그를 찾아내야 된다면.. 정말 머리에서 연기가 피어오를 것이다.
오늘날의 프로그래밍 언어에서는 저것들은 최대한 금기시되고 봉인되고, 다른 형태로 대체되고 있다.

goto는 아무리 사악하다고 하지만 이중 for 문을 한꺼번에 빠져나가기, 그리고 switch와 while/for문을 한꺼번에 빠져나가기 같은 건 너무 아쉽다. 자기보다 뒤로만 goto가 가능하게 제한하는 것도 나쁘지 않을 것 같은데 말이다.
한편, 개발툴에서 define 전개된 결과 기준으로 문자열을 find in files 하는 기능이 있으면 좋겠다는 생각이 가끔 든다.

4. 전처리기 #if 의 동작 방식

C/C++에서 원래 있는 if문 말고, 전처리기의 #if에서는 소스 코드에 있는 변수들을 당연히 전혀 사용할 수 없다. 오로지 #define 심벌과 상수, 기성 연산자만이 사용 가능하며, #define 심벌들은 매크로 치환 후에 다들 상수로 바뀌어야만 한다.

변수나 type이라는 개념이 없기 때문에 대입 관련 연산자는 당연히 전혀 사용할 수 없으며 포인터도 아웃이요, sizeof 연산자도 지원되지 않는다. 그 대신, 어떤 심벌이 #define돼 있는지의 여부를 판별하는 defined라는 고유한 bool값 연산자가 있다.

sizeof는 피연산자가 값이 아닌 타입 명칭일 때는 피연산자를 ( )로 싸지 않아도 된다.
이와 비슷하게, defined도 피연산자가 다른 수식이 아니라 명칭 달랑 하나이기 때문에 ( )가 없어도 된다.

그리고 나도 지난 25년 가까이 전혀 몰랐던 특성이 하나 있는데..
#if 문에서는 정의되지 않은 아무 명칭/심벌을 들이대도 에러 처리되지 않는다. 그런 듣보잡 심벌은 그냥 곱게 상수 0과 동급으로 간주된다~!

무슨 포인터 역참조 할 때 if(ptr && *ptr==1) 이러듯이 #if defined SYMBOL && SYMBOL==1 같은 defined 가드를 설치할 필요가 없다.
SYMBOL 자체가 #define돼 있지 않다면 #if SYMBOL==1은 어차피 자동으로 false로 처리된다.
겨우 이런 사소한 사항 때문에 전처리기가 까탈스럽게 에러를 뱉지는 않으니 걱정하지 않아도 된다.

5. 특수한 코딩 요소

(1) 빌드 configuration이 맞지 않는다면 코드가 아예 빌드되지 않고 고의로 에러가 유발되게 하고 싶을 때가 있다. 이때는 일부러 무식하게 C/C++ 문법에 어긋난 문자열을 늘어놓을 필요가 없이 #error라는 전처리기 지시문을 쓰면 된다.
컴파일러에 따라서는 에러가 아니라 경고 메시지만 흉내 내고 빌드는 계속 진행되게 하는 #pagma message도 표준에 준하는 기능으로 쓰인다. deprecated API를 사용을 권장하지 않는다고 표시하는 것처럼.. 이런 건 언어 차원의 지원이 필요해 보인다.

(2) 파싱과 문법 체크만 할 뿐, 실제 코드를 생성하지 않고 아무 일도 하지 않는 허깨비 유령 함수라는 것도 필요하다. 디버그 로그를 찍는 함수를 조건부로 숨길 때, 템플릿 클래스에 인자가 제대로 주어졌는지 체크할 때 등(static_if 나 컴파일 타임 assert와 비슷)..
이건 _noop라는 컴파일러 인트린식 형태로 제공되는 편이다. 마치 인라인이나 매크로 함수처럼.. 외형은 함수이지만 실제로는 자기 주소가 존재하고 매개변수의 push/pop이 행해지는 함수가 아닌 셈이다.

(3) 내용을 깡그리 무시하고 컴파일러가 파싱하지 않게 하는 영역은 '주석'이라고 불리며, 이건 모든 프로그래밍 언어에 존재한다.
C/C++에서는 /* */ 와 //뿐만 아니라 전처리기를 이용한 #if 0 / #endif도 사실상 주석처럼 쓰일 수 있다.
게다가 얘는 /* */ 와 달리, 중첩이 가능하다. #if 0으로 막혀 있는 구간이라도 전처리기의 #if #else 로직은 무시되지 않기 때문이다. 그래서 중간에 또 #if 0이 섞여 있는 코드라도 한번에 싹 막았다가 해제할 수 있어서 편리하다.

Posted by 사무엘

2023/01/17 08:35 2023/01/17 08:35
, ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/2114

사람의 정치 성향 스펙트럼이라고 해서 백지에다 4개의 구획을 만든 뒤, 좌우로는 말 그대로 좌파와 우파, 상하로는 권위주의와 자유주의(혹은 전체주의와 개인주의) 이렇게 두 축을 표시해 놓은 그림이 있다.

사용자 삽입 이미지

보다시피 둘은 서로 독립적인 변수이다. 좌파라고 해서 다 빨갱이가 아니며 그냥 무정부주의에 가까운 좌파도 있다. 우파 역시 맹목적인 자유뽕에 가까운 성향이 있는가 하면 ‘국익을 위해 멸사봉공’ 이러는 노선도 있다.
두 축에 대해서 하나는 개인에 대한 자유도(상하)이고, 다른 하나는 시장에 대한 자유도(좌우)라고 생각하면 딱 이해가 될 것 같다.

무슨 MBTI 검사하듯이 수십 가지 질문으로 설문 조사를 한 뒤, 자신의 정치 성향을 저 평면 위에다가 찍어 주는 웹 서비스가 많이 있다.
극좌와 극우에 대해서 "극과 극은 통한다" 같은 소리가 종종 나오는 건, 좌우 말고 상하 축이 '전체주의' 쪽으로 일치하기 때문일 가능성이 높다. 그건 좌와 우의 전체 입장을 대변하는 진술은 아닐 것이다.

그런데 사람의 이념뿐만 아니라 프로그래밍 언어의 설계 이념도 이런 식으로 분류 가능하다. 대표적으로 type을 취급하는 방식이다.

사용자 삽입 이미지

먼저 좌우로 static이냐 dynamic이냐 하는 속성이 있다.
변수의 type이 소스 코드에 미리 명시되어서 빌드 때 완전히 붙박이로 고정되는 건 static이다. 정수에는 정수만 집어넣을 수 있고, 문자열에는 문자열만 집어넣을 수 있다.

int a;
string b;
a = 100;
b = "Hello world!!";

그 반면, dynamic은 한번 변수를 선언했으면 거기에 아무 형태의 값이나 집어넣을 수 있다.

var a;
a = 100;
a = "Hello world!!";

우리가 접하는 '가벼운, 인터프리터' 성향의 프로그래밍 언어들은 dynamic type이다. 그러나 exe/dll 따위를 생성할 때 쓰이는 기계어 직통 컴파일 성향의 '무거운' 언어들은 대체로 static type인 편이다.

dynamic은 사람의 입장에서 입문과 코딩이 용이하다. 그러나 코드의 실행 성능은 타입을 꼼꼼히 지정해 주고 이 범위를 벗어나지 않는 static이 훨씬 더 뛰어나다. 코드의 양이 수백, 수천만 줄을 넘어갈 때의 유지보수 난이도과 총체적인 생산성도 static이 더 낫다.

둘의 차이는 똑같이 표 형태의 데이터를 입력하는데 엑셀(스프레드시트)과 전문 데이터베이스의 차이와 비슷하다.
엑셀은 아무 셀에나 아무 값을(숫자, 문자열, 날짜 시간 등..) 아주 자유롭고 편하게 입력할 수 있는 반면, DB는 각 셀별로 들어갈 수 있는 자료형과 크기를 정말 딱딱하게 미리 정해 놓고 그걸 지켜야 한다.

그러나 그 상태로 데이터의 개수가 수백· 수천만 개에 달하면? 데이터를 원하는 대로 검색하고 정렬하고 한꺼번에 변형하는 성능은 스프레드시트가 DB를 절대로 범접할 수 없을 것이다. 유도리, 자유도 같은 건 성능하고는 아무래도 상극이고 등가교환 관계일 수밖에 없다.

하지만 static 언어라 해도 타입이 뻔한 문맥에서 타입 명칭을 일일이 써 주는 건 귀찮고 번거롭다. 특히 변수를 선언과 함께 초기화할 때 말이다. 대입하려는 우변의 값에 타입을 암시하는 정보가 어지간해서는 이미 포함돼 있기 때문이다.

그렇기 때문에 C++에서는 auto라는 파격적인 키워드가 도입돼서 변수 자체의 타입은 static하게 결정되더라도 최소한 int, string 같은 타입명을 번거롭게 쓸 필요는 없게 하고 있다.
또, 템플릿 메타프로그래밍이니 제네릭이니 하는 것을 도입해서 static type 언어이더라도 한 코드를 다양한 type에 대해서 범용적으로 활용 가능하게 해 놓았다. dynamic type 언어라면 저런 물건이 태생적으로 존재할 필요가 전혀 없을 것이다.

함수를 호출할 때는 보통은 값을 인자로 넘기고 값을 리턴값으로 받는다. 그런데 저런 패러다임 하에서는 함수를 호출하거나 클래스의 인스턴스를 선언하면서 타입까지도 인자로 넘기게 된다. 물론 이건 여느 함수 인자와는 성격이 많이 다르기 때문에 통상적인 괄호가 아닌 < >로 감싸고 전달하는 위치도 따로 구분돼 있다.

부등호로만 쓰이던 이항 연산자 < >가 여닫는 괄호처럼 쓰이니 이건 굉장한 발상의 전환이다. 이제는 소스 코드의 파싱도 마냥 단순무식이 아니라 주변 문맥을 의식하면서 해야 하게 됐다.

외형은 비슷해 보여도 C++의 템플릿은 C#/Java 같은 언어들의 제네릭과는 성격이 완전히 극과 극으로 다른 물건이라는 것이 주지의 사실이다. C++ 템플릿이 제네릭보다 자유도가 더 높고 화끈=_=하기는 하지만.. 이건 템플릿의 소스를 몽땅 까고, 서로 다른 템플릿 인자에 대해서 컴파일과 코드 생성이 매번 다시 행해지는 무식한 댓가를 치르는 덕분에 제공되는 장점이다.;;;

참고로 값과 타입에 이어서 { }로 감싸는 함수 몸체 자체까지 함수의 인자와 리턴값으로 마구 주고받을 수 있는 건 그 이름도 유명한 함수형 패러다임이 된다. 이게 제일 나중에 도입돼 있다.

자, static과 dynamic 타입에 대한 소개는 이 정도로 된 듯하고, 다음으로 상하 세로축을 살펴보자.
strong이냐 weak냐 하는 속성은 type safety에 관한 것이다.
서로 관련이 없는 타입의 값끼리 형변환을 알아서 쓰윽 해 주고 위험한 형변환도 별 탈 없이 허용하는 편이면 type safety가 weak인 것이다.

그렇지 않고 뭐 하나 하려면 깐깐하게 형변환 함수를 수동으로 매번 호출해야 한다면, 타입 관련 오류는 대부분 컴파일 때 다 걸러지고 런타임 때 딱히 문제가 발생할 일이 없다면 그런 언어는 strong이다. 단적인 예로,

a = 200 + "abc";

이런 구문을 알아서 "200abc"라고 접수해 주면 weak이고, 숫자와 문자열을 한데 섞을 수 없다고 까칠하게 에러를 내뱉으면 strong인 편이다.
그러면 static인 언어가 strong인 편이고 dynamic인 언어가 weak가 아니겠냐고 편견을 가질 수 있지만.. 실제로는 꼭 그렇지 않다.

같은 dynamic type 언어 중에서도 Visual Basic, JavaScript, 문자열의 유연한 조작에 특화된 Pearl, 그리고 PHP..;; 같은 언어들은 weak로 분류된다.
그 반면, 파이썬은 dynamic type 언어이지만 strong이라고 여겨진다. 둘은 아까 정치 성향과 마찬가지로 서로 별개의 개념이다.

특히 C/C++은 static이면서 weak인 매우 이례적인 언어이다. 이 범주에 드는 언어 자체가 사실상 얘들밖에 없다.
타입 시스템이 static인 것이야 의심의 여지가 없는데, C는 그에 덧붙여 type safety가 굉장히 개판이고 안전 장치가 빈약하기 때문이다.

숫자에서는 enum과 int를 제멋대로 섞어 써도 아무 문제가 없는 것, 0이 포인터와 정수에서 모두 통용되는 것, bool과 숫자의 구분도 없는 것, 관련 없는 타입의 포인터끼리의 대입이 굉장히 관대한 것, 타입의 통제 따위는 전혀 받지 않는 무식한 memcpy와 malloc이라든가 매크로 함수..;; 그리고 부동소수점 숫자의 내부 구조까지 뜯어볼 수 있는 공용체와 비트필드는 C/C++ 말고 도대체 어느 언어에서 찾아볼 수 있을까???

그나마 C++에 와서 무질서도가 눈꼽만치 개선됐다. explicit와 enum class도 도입되고 true/false 상수라든가 nullptr도 도입되면서 type safety를 강화하려고 애쓰는 중이다. 하지만 C++의 type safety는 Java나 C#에 비할 바는 못 된다고 여겨진다.

현대의 언어들은 static/dynamic이야 언어의 취향과 용도에 따라 달라지지만 type safety에 대해서는 strong을 추구하는 쪽으로 바뀌는 추세이다. weak인 언어는 당장 표현은 간결하게 할 수 있고 자유도가 더 높지만.. 안전하다는 보장이 없기 때문이다. 방대한 코드에서 갑자기 버그· 오류가 발생했을 때 지뢰가 어디에 숨어 있는지를 알기가 너무 어려워진다.
따지고 보면 제네릭이 도입된 것도 무식한 void*나 Object 떡칠만 하는 것보다 더 안전하게 코드를 작성하기 위해서이다.

Posted by 사무엘

2022/12/26 08:35 2022/12/26 08:35
, ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/2106

1. 스마트포인터를 인식하지 못하는 버그

회사에서 이미 작성된 C++ 클래스 멤버 함수를 사용하고 싶어서 호출을 했는데.. 컴파일러인지 링커인지가 도무지 말귀를 알아듣질 못하고 unreferenced external symbol 링크 에러를 내뱉곤 했다. 매크로 치환, namespace 그 어떤 문제도 없는데 왜?
더 골때리는 건.. 같은 코드가 Windows에서 Visual C++은 아무 문제 없이 빌드되고, 안드로이드의 NDK 빌드 환경에서만 저런다는 것이었다.

그 함수는 첫째 인자의 타입이 FOO const&이었는데, FOO는 스마트 포인터 std::shared_ptr<BAR>의 typedef였다.
스마트 포인터를 왜 value로 전달하지 않고 또 레퍼런스로 전달했는지, 그 이유는 모르겠다. 이 코드를 처음에 내가 작성한 게 아니니까..

그런데 문제는 저 스마트 포인터를 그냥 날포인터 BAR*로 바꿔 주니까 링크 에러 없이 빌드가 됐으며, 프로그램도 양 플랫폼 다 별 문제 없이 돌아가기 시작했다는 것이다.
어느 경우건 -> 연산자를 쓰면 BAR 내용을 참조할 수 있으며, 몇몇 곳에서만 ptr 대신에 ptr.get()을 호출해 주면 됐다.

결국 이 문제의 원인은 안드로이드 쪽의 컴파일러 내지 링커의 버그이긴 한 것 같다. 하나만 고르라면 링커보다도 컴파일러의 문제인지도? 복잡한 type의 decoration string가 양쪽에서 서로 동일하게 생성되지 못했던 것으로 보인다.

2. 변수에도 extern "C" 구분이 필요한가

C++ 코드에서 다른 C 소스 파일에 정의된(C 소스로부터 빌드된 obj, lib도 포함) 함수를 참조해서 호출하려면.. 그 함수의 prototype이 extern "C" 형태로 선언되어야 한다.
C++은 오버로딩이라는 게 존재하기 때문에 C와 달리 함수를 이름만으로 유일하게 식별할 수 없으며, 인자들의 개수와 타입들도 명칭 decoration에 다 들어가야 하기 때문이다.

이건 상식 중의 상식이다. 그렇기 때문에 C언어 방식으로 만들어진 라이브러리는 헤더 파일이 중복 include guard뿐만 아니라

#ifdef __cplusplus
extern "C" {
#endif

(.....)

#ifdef __cplusplus
}
#endif

이렇게 관례적으로 감싸져 있기도 하다. C++ 코드에서 인클루드 되더라도 여기 함수들은 C++이 아닌 C 방식으로 링크 하라고 말이다.

그런데.. 난 함수뿐만 아니라 전역 변수도 이런 decoration 방식이 차이가 존재하며, 서로 일치해야 한다는 걸 요 근래에야 처음으로 알게 됐다.
C++이 C 코드에서 선언된 전역 변수를 참조하려면.. 역시 extern "C" int Global_in_C_code; 이렇게 해 줘야 된다. extern "C"를 생략하면 링크 에러가 난다..;;

헐 왜 그렇지..?? 변수는 언어 문법 차원에서 decoration이 전혀 필요해 보이지 않는데..?? Visual C++만 그런가?

그러고 보니 Visual C++은 함수를 C++ 형태로 decoration을 할 때 인자뿐만 아니라 리턴 타입까지 그 함수의 prototype의 모든 정보를 써 넣는다.
함수의 리턴 타입은 오버로딩 변별 요소가 아니기 때문에 "굳이 써 넣을 필요가 없음에도 불구하고" 그리한다는 것이다.

그런 것처럼 그냥 completeness 차원에서.. 나중에 미래에 혹시 필요할지도 모르니까 변수도 C++ 방식에서는 자신의 type까지 다 꼼꼼히 써 넣는 게 아닐까? 나로서는 이렇게밖에 생각되지 않는다.
예전에 C++에서는 const 전역 변수는 반드시 extern을 명시해 줘야 다른 번역 단위에서도 참조 가능해진다는 걸 알지 못해서 오랫동안 컴파일러/링커의 난독증을 의심하며 짜증 냈던 적이 있었는데.. 이것도 좀 비슷한 상황인 것 같다.

심지어 extern "C" 다음에 { }를 쳐서 C 방식의 외부 전역 변수 선언을 여러 개 하려면 중괄호 안에다가 extern을 또 써 줘야 된다. extern "C" { extern int x,y,z; } 처럼.

extern "C" { int x,y,z; }
이렇게 하면 x,y,z가 이 번역 단위 안에서 몸체가 직접 정의돼 버린다. 그렇기 때문에 unresolved symbol 대신, 명칭 중복 선언 충돌이라는 링크 에러가 날 수 있게 된다.

즉, 선언만 하고 마는 것은 중괄호와 함께 extern을 또 명시한 extern "C" { extern int x,y,z; } 이거 아니면..
그냥 extern "C" int x,y,z; 둘 중 한 형태라는 것이다. 어휴~ ㄲㄲㄲㄲ

3. 에러 안내

(1) 컴파일 에러는 컴파일러가 지적해 준 부분의 주변만 유심히 살펴보면 대체로 쉽게 해결 가능하다. 아주 복잡하게 꼬인 템플릿 코드에서 컴파일러가 뜬구름 잡는 난해한 소리만 늘어놓는다면 그건 상황이 다르지만, 그 정도로 극단적인 상황은 흔치 않다.
그 반면, 컴파일 에러보다 훨씬 더 무질서도가 높고 난해한 에러는 링커 에러일 것이다.

요즘 컴파일러는 명칭의 오타 때문에 에러가 나면 근처의 스펠링이 비슷한 변수· 함수를 제안까지 하면서 "혹시 이걸 의도하셨습니까?" / "혹시 뒤에 세미콜론을 빠뜨렸습니까?" 이런 안내를 할 정도로 똑똑해졌다.
링커도 "동일한 명칭이 C 방식으로는 존재하는데 혹시 extern "C"를 빠뜨렸습니까?" 정도의 유사 명칭 안내는 해 줘야 하지 않나 싶다.

(2) 아 하긴, C++ 템플릿은 그 자체만으로는 컴파일러가 문법 검사를 전혀 하지 않으며, 그 구조상 할 수도 없다.
템플릿에 인자가 주어져서 어떤 타입에 대한 실체가 생겼을 때에만 컴파일러가 그에 대한 코드를 생성할 수 있으며, 이때 비로소 문법 검사가 행해진다.

템플릿과 관련해서 발생하는 컴파일 에러는 뭔가.. 한 박자 다음에 발생한다는 점으로 인해 링커 에러처럼 더욱 난해한 구석이 있다.
템플릿 인자가 그 어떤 형태로 주어지더라도 무조건 발생할 수밖에 없는 컴파일 에러는 템플릿 자체의 코드만 보고도 컴파일러가 먼저 딱 잡아낼 수도 있으면 좋겠다만.. C++ 컴파일러 업계에서 그런 건 아직 신경을 안 쓰는가 보다. 메타프로그래밍이란 건 아무래도 추상화 수준이 높고 매우 난해한 기술이기도 하니 말이다.

4. 버전이 올라가면서 달라지는 C++ 컴파일러 동작

cmake라고 플랫폼별로 파편화돼 있는 개발툴 프로젝트/빌드 스크립트를 한데 통합해 주는 프로그램이 있다.
이건 분명 현실에서의 난해하고 복잡한 문제를 단순화시키고 해결하기 위해 만들어진 도구이겠지만.. 본인은 오픈소스나 크로스 플랫폼 같은 쪽으로는 인연이나 경험이 없다시피한 Windows 토박이에 Visual Studio 매니아이다 보니 얘를 다루는 게 참 난감하고 버겁게 느껴졌다.

회사에서 굉장한 구닥다리인 Visual Studio 2013을 오랫동안 쓰고 있어서 이걸 2019로 올리고, 플랫폼도 x86뿐만 아니라 x64도 추가하고 싶은데.. 그러려면 cmake 스크립트를 어떻게 바꿔야 하는지 알 길이 없었다.

나중에 알고 보니 cmake 자체도 버전업을 해야 했다. 그런데 VS가 2013이 없고 2019만 있을 때 발생하는 에러 메시지들이 그 근본 원인과는 전혀 관계 없는 엉뚱한 것들이어서 에러 메시지가 짚어 주는 부분만 뒤져서는 문제의 원인을 도무지 알 수 없었다.

cmake 따위 없이 Visual Studio 솔루션과 프로젝트 파일만 있었으면 이건 뭐 일도 아니었을 텐데 이런 것들이 cmake 스크립트가 좀 유연하지 못한 구석이 있는 것 같았다. 특정 Visual Studio 버전과 특정 타겟 아키텍처에 매인 비중이 크다. 뭐, 사실은 본인이 cmake 사용법을 잘 몰라서 삽질하는 것이겠지만..
cmake나 git 같은 빌드 관련 툴들은 학교에서 가르치기에는 너무 남사스럽고, 학원도 아니고.. 천상 스스로 독학하거나 직장에서 알음알음 배우는 수밖에 없나 모르겠다.

그리고 이렇게 컴파일러를 업글 하고 나면.. 기존 코드가 자잘하게 컴파일이 안 되는 부분이 꼭 발생하곤 한다. 그런 건 내 경험상.. C++이 갈수록 type safety가 강화되어서 더 까칠 엄격해지기 때문인 것 같다.
직장에서의 경험을 회고해 보자면, 이 클래스가 이 상태로는 vector, list, set 같은 컨테이너에 들어가지 않아서 에러가 나곤 했다. 2013에서는 됐는데 2019에서는 안 되는 것이다.

operator =의 인자가 T였던 것을 const T&로 바꾸고, 복사 생성자가 정의돼 있지 않던 것을 명시적으로 넣어 주고, 원래는 생성자에다가 U라는 타입 값을 넣으면 자동으로 형변환이 됐는데 이제는 되지 않아서 명시적으로 형변환을 하는 등.. 에러를 해결하는 방식이 다들 이런 식이었다.

Posted by 사무엘

2022/07/08 08:35 2022/07/08 08:35
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/2040

1. timestamp 기준, 그리고 달력 계산 문제

프로그래밍 언어 내지 운영체제 API에서 현재 시각과 관련된 정보를 얻는 함수는 다음과 같은 두 그룹으로 나뉜다.

  • 가변: 현재 프로그램이나 운영체제가 시작된 이래로 현재까지 경과한 시간을 밀리초나 그에 준하는 정밀한 단위로 되돌림. C언어의 clock() 함수, 또는 Windows API의 GetTickCount()가 이쪽에 속한다. 얘는 현재 날짜 시각을 얻는 용도가 아니라 그냥 짤막한 소요 시간 측정용이다.
  • 고정: 특정 고정 시점 이래로 현재까지 경과한 시간을 초 정도의 정밀도로 되돌림. C언어의 time() 함수가 대표적인 예이며 timestamp 저장용으로 쓰인다. 단, 고정 시점 기반이면서 정밀도도 초보다 더 높은 물건도 있다.
  • 날짜형: 애초에 출력 형식이 년-월-일-시-분-초가 따로 담긴 구조체이다. C언어에서는 time()의 결과값부터 구한 뒤에 gmtime이나 localtime을 호출해서 이렇게 변환해야 하지만, Windows API는 반대로 GetSystemTime/GetLocalTime을 이용해서 구조체부터 구한 뒤에 SystemTimeToFileTime을 호출하는 형태이다. 원론적으로는 C언어 방식의 순서가 더 자연스러울 것이다.

컴퓨터에서 특정 시각 timestamp를 저장하는 방식으로는 유닉스에서 유래된 "1970년 1월 1일 0시 이래로 경과한 초수"가 아주 널리 쓰인다.
하지만 그것 말고 NTP라고 네트워크 환경에서 통용되는 timestamp도 있는데, 얘는 10진법 계산의 편의를 염두에 둬서 그런지 1900년 1월 1일 0시가 기준이다. 두 timestamp는 70년이라는 격차가 존재하는 셈이다.

그런데 부호 있는 32비트 정수 자료형이 초 단위로 표현할 수 있는 기간, 즉 21억 5천만 초는 약 68년이어서 이 역시 공교롭게도 70년에 얼추 가깝다.
부호 있는 32비트 정수 기준으로 유닉스 timestamp는 2038년쯤에 overflow가 발생할 것으로 예상된다.
그 반면, 부호 없는 32비트 정수 기준으로 NTP는 2036년쯤에 overflow되어 숫자가 리셋될 예정이다.

본인은 직장에서 유닉스 timestamp를 네트워크 timestamp로 변환하는 함수를 구현할 일이 있었다.
기존 timestamp에다가 1900년 1월 1일부터 1970년 1월 1일까지의 초수라는 상수를 더해 주기만 하면 되니, 난 그 상수는 엑셀을 띄워서 간단히 구해서 썼다. 엑셀도 1900년 1월 1일이 기준이라는 걸 알고 있었기 때문이다.

그런데 이렇게 날수를 더해 줬더니, 계산 결과가 미묘하게 맞지 않고 하루 정도 오차가 났다.
그리고 그 원인은 alas... 엑셀은 1900년을 평년이 아닌 윤년으로 간주하고 하루를 더 집어넣었기 때문이었다.

현행 그레고리 태양력은 4의 배수인 해가 윤년이어서 2월이 29일까지 존재하게 되지만, 100의 배수인 해는 400의 배수인 해만 윤년으로 인정하고 나머지는 평년으로 간주한다.
하지만 이런 예외가 먼 197, 80년대의 스프레드 시트 프로그램에서는 구현하기가 너무 복잡했던 모양이다.

더구나 서기 1900년은 어차피 컴퓨터가 발명된 해 기준으로는 까마득한 옛날이어서 실용적인 의미가 없으니.. 윤년은 "그냥 4년 주기"라는 율리우스 달력 로직만 구현했던가 보다. 그리고 엑셀 역시 1900년 2월 29일이 존재할 수 있는 '버그'까지 똑같이 기존 프로그램(= Lotus 1-2-3 따위)과 호환성을 보장하기 위해.. 동일한 로직을 일부러 구현했다.

엑셀이 이렇게 윤년을 잘못 계산하는 건 1900년 하나뿐이니 걱정하지 않아도 된다. 미래의 서기 2100년이나 2200년은 평년으로 정확하게 계산하며, 2400년만을 윤년으로 계산한다.
이 동작이 영 껄끄러운지, 엑셀은 각 문서 파일에 대해 고급 옵션으로 "Use 1904 date system" 여부라는 걸 지정해 줄 수 있다. 논란의 여지가 있는 1900년이라는 걸 아예 삭제해 버리고 건너뛴 것 같은데.. 이러나 저러나 사용자에게는 큰 의미가 없고 널리 쓰이지는 않는 옵션으로 보인다.

어떤 경우건 엑셀에서 1899년 9월 18일 경인선 개통일을 날짜 타입으로 집어넣을 수는 없다. ㄲㄲㄲㄲㄲ 다른 날짜와 연계해서 연산을 할 수 없으며, 전화번호처럼 문자열로만 취급 가능할 뿐이다.

2. 핸들(포인터) 값을 대체하는 순서

GC가 없는 언어인 C++로 코딩을 하다 보면 각종 자원(메모리나 리소스, 객체)을 가리키는 포인터 및 핸들을 감싸는 wrapper 클래스를 만들 때가 많다.
그 클래스의 소멸자에는 if(_ptr) Free_Release_Close_Destroy(_ptr)처럼.. 핸들이 가리키는 자원을 해제하는 함수 호출이 들어가곤 한다. 그리고 객체 자체가 소멸되지는 않고 객체가 가리키는 핸들값만 바뀔 때도 기존 핸들에 대한 해제 작업이 자동으로 행해진다.

_ptr이라는 핸들 멤버를 갖고 있는 클래스에서 핸들값을 newVal로 변경하는 작업을 직관적으로 생각하면 다음과 같을 것이다. _ptr을 해제한 뒤 거기에다 바로 새 값을 대입하는 것이다.

if(_ptr && _ptr!=newVal) Free_Release_Close_Destroy(_ptr); //원래 핸들을 제거한 뒤
_ptr = newVal; //새걸로 대체

하지만 구조적으로 더 안전한 정석은 아래와 같이 임시 변수를 만들어서 두벌일을 좀 하는 것이다.

auto tmp = _ptr; _ptr = newVal; //새걸로 대체부터 한 뒤에
if(tmp && tmp!=newVal) Free_Release_Close_Destroy(tmp); //원래 핸들을 제거

핵심은 기존 핸들값을 다른 지역변수에다 옮긴 뒤, 자기 자신의 핸들을 먼저 새 값으로 바꿔 버리고, 지역변수에 대해서 해제 함수를 호출하는 것이다. 이거 무슨 swap 함수처럼 보이기도 하는데..
이렇게 해 주면.. 자기 자신이 해제되고 있는 중에 멀티스레드 등 모종의 이유로 인해서 해제 메소드가 또 호출됐을 때, 해제가 중복으로 행해지는 걸 막을 수 있다. 왜냐하면 자기의 핸들값은 대외적으로 이미 NULL 같은 딴 값으로 바뀌어 있기 때문이다.

실제로 C++의 스마트 포인터만 해도 unique_ptr::reset 같은 함수의 몸체를 보면 저렇게 임시 변수 대입, 멤버 변수 대입, 임시 변수에 대한 release 순으로 구현돼 있다.
분야가 좀 다르지만.. 전기 철도에서 팬터그래프는 안전을 위해 언제나 진행 방향 기준으로 최대한 뒤에 장착되는 것과 비슷한 이치로 보인다.

3. 이분 검색의 변종

모든 원소에 임의 접근이 가능한 배열의 경우, 원소들이 정렬돼 있다면 특성 원소를 찾을 때 '이분 검색'이 가능해서 O(n)이 아니라 O(log n)의 시간 복잡도로 작업을 수행할 수 있다. 비교를 한 번 할 때마다 후보군이 그거 하나만 없어지는 게 아니라 통째로 반토막이 나기 때문이다.
그런데 정렬된 배열에 대해서 원소 하나만 딱 정확하게 찾는 게 장땡이 아니고 다음과 같은 작업을 생각할 수 있다.

  • "1, 4, 8, 11" 같은 배열에서 5나 2, 10, 15 같은 새로운 원소를 삽입해 넣고 싶은데 어느 지점이 좋을까? (당연히 정렬된 상태 유지)
  • "1, 4, 8, 8, 8, 8, 11" 같은 배열에서 8이 정확하게 어느 오프셋부터 시작되어 어디에서 끝나는지 알고 싶다.

이런 것은 이분 검색의 변종이며, 이 역시 당연히 log n 시간 복잡도로 수행 가능하다. 정확한 이분 검색이 방정식이라면 이런 건 뭔가 부등식에 대응하는 것 같다. 날개셋 한글 입력기의 내부 동작에서도 종종 쓰이는 기능이다.

개인적으로는 타 비교 함수의 결과를 저런 용도대로 보정· 변조하는 2차 비교 콜백 함수를 만들어서 C의 bsearch 함수만으로 저런 기능을 구현했던 적이 있었다.
즉, 원래 사용하는 1차 비교 함수가 원소값이 동등하다는 0을 리턴했더라도, 바로 앞의 원소에 대해서 또 1차 비교를 했는데 걔가 또 0이라면.. 이 원소값에 대한 비교는 여전히 -1을 되돌리도록 보정하는 식이다. (탐색 지점을 앞으로 더 옮기게..)

그랬는데 C++에서는 사정이 더 좋아져서 이런 기본적인 동작은 algorithm이라는 라이브러리에 lower_bound, upper_bound, equal_range라고 내가 딱 원하던 함수들이 도입됐다. 포인터처럼 임의 접근이 가능한 iterator가 있다면 저 함수에다 바로 집어넣어 줄 수 있다.
하긴, 정렬도 qsort 하나뿐만 아니라 특별히 안정성 있는 stable_sort도 있고, 정렬되어 있는 두 컨테이너를 병합하는 함수도 있고.. 이런 것들이 algorithm의 섬세한 면모인 것 같다.

그런데 문제는 배열이 아닌 binary tree 형태로 정렬된 상태가 유지되는 컨테이너이다. set과 map..
얘들을 다룰 때 사용되는 iterator는 원소들의 임의 접근이 가능하지 않으며, 반대로 tree 노드의 좌우 이동 같은 게 iterator와 연계되지도 않는다.

물론 multiset도 아닌 이런 컨테이너에 equal_range이야 전혀 의미가 없을 것이고 새 원소 삽입 지점 같은 걸 찾아야 할 필요도 없을 것이다. 하지만 "들어있는 문자열 중에서 B로 시작하는 제일 첫 명칭은?" 같은 검색을 할 필요는 있다.
그렇기 때문에 set과 map에는 lower_bound와 upper_bound가 범용적인 함수가 아니라 클래스의 자기네 전용 멤버 함수로 구현되어 있다. 역시 C++ 라이브러리가 이런 걸 빠뜨리지는 않았고, 배열과 set/map에 대해서 대동소이한 형태로 동일 취지의 기능을 구현했다는 걸 뒤늦게나마 경험할 수 있었다.

Posted by 사무엘

2022/03/29 08:35 2022/03/29 08:35
, , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/2003

남의 코드를 읽으면서 신기하게 느꼈던 점들을 다음과 같이 정리했다. 요즘 C++은 변해도 너무 많이 급격하게 변하고 있는 것 같다. 뭔가 다른 언어에 있던 기능이 비슷한 형태로 그대로 도입되는 편이다.

1. final과 override

본인은 C++에 클래스에 뭔가 제약을 가하는 기능이 부족한 편이라고 볼멘소리를 늘어놓아 왔다. 어떤 클래스가 더 상속이 되지 않게 하기, 이 함수가 더 오버라이딩이 되지 않게 하기, 대입이나 복제가 되지 않게 하기 등...
하긴, 이런 불평은 나만 하는 게 아니며, 오히려 나보다 더 깐깐한 불편러 PL 순수주의 성향인 사람도 많다.

함수 차원에서 제약을 가하는 것은 요즘 C++에서는 = delete 문법이 생겨서 불편이 많이 해소됐다.
그런데 이것 말고도 C++에 final과 override라는 조건부 키워드도 드디어 추가되었다니 참 놀랍다.

class Parent final { }

요렇게 해 주면 Parent를 기반으로 삼는 파생 클래스를 만들 수 없다. class Child: public Parent {} 이런 걸 시도하면 에러가 난다.
한편,

class Parent {
public:
    virtual void foo() {}
};
class Child: public Parent {
public:
    void foo() override {}
};

여기서 override는 Child의 foo가 기반 클래스의 foo를 재정의한다는 것을 나타낸다. 이 표기는 당연히 전적으로 optional이기 때문에 하든 안 하든 컴파일러의 코드 생성과 프로그램의 실행에는 아무 영향을 주지 않는다.

단지, 기반 클래스에 저런 함수가 없는데도 파생 클래스의 함수에 override가 선언돼 있다면 컴파일러는 에러를 찍어 준다. 그러므로 저걸 집어넣으면 내가 함수의 스펠링이나 매개변수를 실수로 잘못 넣었는지 여부를 곧장 알 수 있다.
그리고..

void foo() final;

final을 집어넣으면 짐작하다시피 이 함수는 파생 클래스에서 오버라이딩을 할 수 없게 된다. override와 final을 동시에 지정하는 것도 가능하다.

멤버 함수의 선언 뒤에다가 뭔가 속성을 지정한다는 점에서 const와 비슷해 보인다. 허나, 다시 말하지만 얘들은 전적으로 컴파일 때의 편의를 제공하는 hint일 뿐이다. 코드의 생성 방식이나 심지어 명칭의 decoration에도 전혀 영향을 주지 않는다.
const는 이거 지정 여부로 함수 오버로딩을 가능하게 하는 변별 요인이지만 override와 final은 그렇지 않다는 것이다. 그렇기 때문에 클래스 안에 함수의 선언부에다가만 지정하고 함수의 몸체 정의에다가는 생략도 가능하다. const는 그렇지 않다.

2. [[??]] 속성 지정자

함수의 선언에서 리턴 타입보다도 먼저 맨앞에 붙어 있는 [[nodiscard]] 이런 문구가 정체가 무엇인지 궁금해서 찾아 봤는데..
이 함수의 리턴값을 무시하지 않게 하는 자잘한 속성 지정자였다. 이 함수는 호출만 하고 리턴값을 무시하는 경우, 호출하는 쪽의 코드에다가 경고를 날리게 된다.

&를 두 개 써서 R-value 참조자라는 걸 추가했듯이, 여는 대괄호도 2개를 써서 저런 새로운 문법을 만든 것이다.
nodiscard 말고도 컴파일러의 최적화 전략에 단서를 제공하는 속성이 몇 가지 더 존재하며, C++ 언어의 버전이 올라가면서 아이템들이 추가되곤 했다.

함수의 선언에는 함수의 이름, 리턴 타입, 그리고 인자들 목록과 타입만 있는 줄 알았는데 그 사이에 calling convention 지정부터 시작해서 갖가지 추가적인 정보와 단서들을 지정하는 문법이 야금야금 도입돼 왔다.

extern "C"도 있고, 그리고 Visual C++이 전통적으로 사용한 방법은 __declspec(????)이다.
특히 deprecated는 [[]]와 __declspec()에 모두 존재해서 이제 기능이 완벽하게 겹치는 것 같다. 자기들이 필요하니까 마소에서 먼저 deprecated API를 지정하는 속성을 비표준 방식으로 추가했는데 그게 이제야 표준에도 도입된 셈이다.

그런데 C/C++은 태생적으로 함수를 선언할 때 function이나 그에 준하는 별도의 키워드를 두지 않았고, "리턴값 함수명(인자)"라는 문법 형태만으로 함수를 선언 및 정의할 수 있게 해 놓았다. 그러다 보니 그 사이에다 추가적인 정보를 집어넣는 문법이 좀 지저분해진 것은 피할 수 없어 보인다.
관점에 따라서는 아까 저 final, override 같은 힌트 속성도 [[]] 형태로 일관되게 넣을 수도 있어 보이지 않는가?

이런 부가 정보들을.. 단순히 경고만으로 끝나는 것, 컴파일 가능 여부에 영향을 주는 것(final), 코드의 생성 방식에 영향을 주는 것(호출 규약), 최적화 방식에 영향을 주는 것, C++ name mangling에 포함되는 것(const) 등으로 한데 분류해 보는 것도 의미가 있을 것 같다.

3. Variadic macro

요즘 C/C++에서는 #define 매크로 함수도 마지막 인자에다가 ...를 줘서 가변 인자 형태로 만들 수 있다.

이게 없던 옛날에는 가변 인자를 받는 함수를 매크로 함수로 간단하게 치환할 수 없어서 그냥 이름만 치환하는 매크로 상수를 써야 했다. 그리고 매크로 상수로는 가변 인자의 앞에다가 추가적인 인자를 삽입해서 다른 함수를 호출하는 식의 응용을 할 수 없었다. 하지만 이제는 그게 가능해졌다.

물론 가변 인자라는 건 근본적으로 C++의 이념과 그닥 어울리지 않는 물건이다. C++이 자체 제공하는 함수 오버로딩이나 default argument와 충돌하기 쉬우며, type-safety와 객체지향(특히 임시 객체에 대한 생성자/소멸자) 처리가 보장되지 않는다. 그러니 가변 인자로 주고받는 건 반드시 정수나 포인터 같은 단순 POD로 한정돼야 한다.

C++과 어울리는 물건이 아니다 보니 얘는 auto니 템플릿이니 하는 쪽에 관심이 온통 쏠려 있는 modern C++의 산물이 아니다. C99에서 맨 처음 도입됐던 것을 C++11이 나중에 같이 받아들인 것이다.
애초에 #define 전처리기도 C++보다는 꽤 C스러운 물건이다. 거기에 또 다른 C의 냄새가 풀풀 풍기는 ...이 잘 결합한 것 같다.

전처리기에는 ##이라는 연산자가 있어서 기존 명칭의 스펠링에다가 뭘 앞뒤로 붙여서 새로운 명칭을 만들게 해 준다.
그것처럼 가변 인자 매크로의 내부에는 가변 인자들 묶음을 한꺼번에 나타내는 __VA_ARGS__라는 특수한 매크로 상수가 정의되어서 사용 가능하다. 가변 인자 지원을 위해서 언어 문법이 확장이라면 확장된 셈이다.

사실, 이게 문법도 변형이 존재한다..

#define my_printf(a, ...)   printf(a, __VA_ARGS__) //A형
#define my_printf(a, args...)   printf(a, ##args ) //B형

지금은 A형이 표준인데 GNU C에서는 B형도 존재했는가 보다. Visual C++에서는 B형은 지원되지 않는다.

요즘 가변 인자가 활발하게 쓰이는 분야 중 하나는 printf 가변 인자 스타일로 문자열을 포매팅하는 디버그 로그 쪽일 것이다.
개발자가 잠깐 보고 마는 정보이니 문자열까지 쓸 것도 없이 간단히 char buff[256]으로 때워도 무관할 것이고 굳이 C++ string이나 stream을 쓸 필요가 없다. 더구나 이거야말로 디버그 빌드 여부냐에 따라 각종 조건부 컴파일과 전처리기 치환이 절실히 필요한 분야이니.. 가변 인자 매크로는 생각보다 개발 명분과 정당성이 풍부해 보인다.

추신:
글을 다 써 놓고 나중에 알고 보니 C++도 variadic macro와 비슷한 개념이 더 괴물 같은 형태로 템플릿에 이미 도입되었다.;; 이름하여 variadic template. template<typename... T> void foo(T.. args) {} 이러면 args가 __VA_ARGS__와 얼추 비슷한 argument pack 역할을 하게 된다.
이것 말고도 온갖 복잡한 용법이 많다. 이거 예시를 보이기 위해서 C++ 코드에다가 굳이 printf를 호출하려고 애쓰는 걸 보니 뭔가 느낌이 짠하다.

4. 현재의 함수 이름을 나타내는 매크로 상수

ANSI C에는 디버깅을 위해 __FILE__, __LINE__처럼 현재 컴파일 되는 파일 이름과 줄 번호로 유동적으로 치환되는 표준 매크로가 정의되어 있다. 이런 게 디버그 로그 내지 assert failure 매크로에서 즐겨 쓰인다.

그런데 현업에서는 이 뿐만 아니라 현재의 코드가 소속되어 있는 함수 이름 문자열을 나타내는 매크로도 쓰인다.
ANSI C 급의 원조 표준은 아니지만 #pragma once나 __super처럼 업계에서 오랫동안 사실상의 표준이나 마찬가지였던 물건이 있는데.. 바로 __func__이다.

얘는 C++11에서는 결국 정식 표준으로 등재되기도 했다. C++ 문법과 관계 있는 물건은 아니니 가변 인자 매크로처럼 C99에서 먼저 도입됐던 것이 추후 수용된 게 아닌가 싶다.
그리고 __FUNCTION__이라는 바리에이션도 __func__과 동일한 역할을 한다.

Posted by 사무엘

2021/08/12 08:33 2021/08/12 08:33
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1920

1. 경고와 에러

C/C++ 컴파일러가 소스 코드를 컴파일 하는 중에 내뱉을 수 있는 메시지는 흔히 에러 또는 경고라는 두 종류로 나뉜다. 그런데 이걸 더 세분화하면 에러의 앞에는 수위가 더 높은 ‘심각한 에러’(fatal error)라는 게 있다.

얘는 컴파일 중에 컴파일러 자체가 뻗거나 메모리가 부족할 때처럼, 외부 요인에 의해 컴파일이 더 진행될 수 없을 때 나는 편이다. 그런 게 아니면 소스 코드가 문법상으로는 이상이 없지만 각종 수식이나 명칭 선언이 괄호가 너무 깊게 들어가고 복잡할 때, 리터럴 데이터 같은 게 너무 많아서 도저히 감당이 안 될 때, #include 깊이나 #define 치환 단계가 너무 깊을 때..

한 마디로 컴파일러의 한계 때문에 코드 생성이 안 되는 것이 심각한 에러로 분류되는 편이다. 이건 통상적인 컴파일 에러와는 성격이 다르기 때문이다. 참, #include 파일을 아예 찾을 수 없는 것, 그리고 #error로 대놓고 에러를 발생시킨 것 역시 추가적으로 심각한 에러의 범주에 든다.

일례로, int a = 999999999999999999999999; 이런 거야 상수가 너무 커서(32비트 범위 초과) 토큰의 스캐닝 단계에서 튕겼기 때문에 일반 컴파일 에러이다.
하지만 int tbl[] = { 10,45,34,33, ... }; 다음에 숫자가 한 100만 개쯤 있다거나,
char msg[] = "......" \ 이런 리터럴이 100MB쯤 이어져서 컴파일이 실패하는 것은 심각한 에러가 되는 셈이다.

그리고 괄호들이 닫히지 않은 채로 구문을 종료하는 세미콜론이 나오면 일반 에러이지만.. 그 상태로 파일 내용이 끝나 버리면 보통 심각한 에러로 간주된다.

에러 말고 경고는.. 컴파일러들이 경고를 이미 여러 단계로 분류해 놓은 편이다. 가령, 초기화되지 않은 변수를 사용하는 것은 다소 심각한 수위의 경고이지만.. 선언만 해 놓고 사용하지 않은 변수는 상대적으로 덜 심각한 경고이다.

또한 요즘은 정적 분석기가 함수 인자의 annotation까지 참조해서 미주알고주알 지적해 주는 잠재적 오류 가능성도 경고의 연장선이라고 볼 수 있다. "null 포인터를 참조할 가능성이 있다, 버퍼 오버런이 발생할 수도 있다" 따위 말이다.
요즘 세상에 코딩을 글쓰기에다 비유하자면 컴파일· 빌드는 인쇄· 배포에 대응하고, 정적 분석은 맞춤법 검사기와 비슷해 보인다.

2. 빌드 툴들이 말귀를 도무지 못 알아들을 때 확인해 볼 사항

  • 그 소스 파일이 프로젝트에 포함돼 있긴 한가? 포함돼 있더라도 혹시 exclude from build 이런 낚시 옵션에 걸려 있지 않은가?
  • 문제의 구간이 #if 조건을 만족하는 구간에 속해 있는가?
  • 명칭이 이상한 매크로 때문에 다른 엉뚱한 형태로 치환되고 있지는 않은가? (주로 C)
  • C++의 경우, 복잡한 namespace나 using 으로 인한 문맥 차이가 존재하지 않는가?
  • 링크 에러의 경우, extern "C"로 인한 name mangling 방식 차이가 존재하지 않는가?

3. 빌드 속도

특별히 다른 부하가 걸린 게 없는 멀쩡한 개발자용 평균 사양의 2~3GHz급 컴터에서.. 2000줄 이하의 평범한 복잡도의 C++ 소스 파일이.. (IOCCC 입상작급 기괴한 난독화, 다단계 템플릿, namespace, #define 떡칠, 다중 다단계 상속 등의 남발.. 이 아닌 "평범한". -_-)
그것도 네트웍도 아닌 로컬 환경에서, 더구나 딱히 빡세게 최적화를 걸지도 않은 디버그 빌드가 컴파일하는 데 개당 0.8초 이상씩 걸리는 건.. 본인은 납득하기 어려운 상황이라고 간주한다. 소스 코드의 #include 구조 및 빌드 시스템에 문제가 있을 가능성이 높다.

당장 precompiled header가 제대로 적용돼 있는지, 덩치 큰 라이브러리 헤더의 연쇄 인클루드와 파싱이 무식하게 매번 반복되고 있지 않은지부터 확인해야 한다. 저건 컴터한테나 인간에게나 좋지 않은 상황이다.
DB 테이블로 치면 primary key 지정이나 인덱싱과 비슷한 최적화가 필요하다.

4. 안드로이드 앱용 JNI 라이브러리의 빌드

(1) 안드로이드 앱을 개발할 때, 겉에서 돌아가는 java 내지 kotlin 코드 기반의 프로그램이야 로컬 환경에서 Android Studio로 간편하게 빌드할 수 있다. 이 IDE는 Windows용과 mac용이 모두 깔끔하게 존재한다.
하지만 이 앱이 내부적으로 사용하는 native code 라이브러리들의 빌드 환경은 내 경험상 mac이건 리눅스건 여전히 유닉스 기반 터미널에 의존하고 있다. Windows에서 바로는 안 된다는 게 특이한 점이다.;; JNI 쪽 빌드도 IDE와 연계해서 같이 되게 할 수는 없는지..

(2) 디버깅도 앱은 breakpoint와 step in/out/over, 지역변수 값 확인, call stack 같은 통상적인 방법론이 IDE 차원에서 모두 지원되는 반면, 그 아래의 라이브러리는 그렇게 할 수 없다. 그런 내부 동작은 로그 printf 신공에 의존해서 추적하는 수밖에 없으니 몹시 불편하다.

(3) 그래도 이런 라이브러리들은 빌드 시스템이 멀티코어/멀티스레드 환경과 굉장히 잘 연계하는 편이다. 그래서 고성능 빌드 서버에서 make -j8 , -j16 이런 식으로 코어 수를 늘려 주면 빌드 속도가 정말 눈에 띄게 매우 빨라진다.
그런데 이 시설에도 이 기능에도 매우 아쉬운 점이 있는데... 코어 수가 늘어나면 빌드 에러 메시지도 진짜 정신없게 중구난방으로 튀어나와서 확인이 어려워진다는 것이다.

Visual Studio처럼 메시지의 앞에 코어 내지 프로젝트의 번호라도 좀 찍어 주면 읽기가 좀 더 수월할 텐데 말이다.
그리고 터미널 접속 프로그램의 본좌인 putty에는 특정 단어나 문자열이 등장했을 때 highlight를 시켜 주는 간단한 기능이 좀 있었으면 좋겠다.

putty는 20년이 넘게 0.x대의 버전 번호를 고수하고 있고, 유니코드(W)가 아닌 ANSI API를 사용하는 게 이색적이다.
ANSI API, 0.x 버전, 크로스 플랫폼 공개 소프트웨어라는 점에서는 DOSBOX하고도 무척 비슷하다.

5. 구조체 전방 선언의 부작용(?)

C/C++ 코드에서는 모듈 간의 include 의존도(= coupling)을 낮추기 위해서 자신이 내부적으로 취급하는 구조체는 불완전하게 전방 선언만 명칭만 노출하는 경우가 많다. 외부에서는 전방 선언 구조체의 포인터만 핸들 마냥 갖고 있고, 실제 조작은 실제 내부 구조를 아는 함수의 호출을 통해서를 하는 것이다. 뭐, 이게 C++이 말하는 정보 은닉과도 일맥상통하는 개념이며, 충분히 바람직한 디자인 패턴이다.

하지만 디버깅을 하는 상황이라면 어떨까..??
조작하는 함수로 들어가기 전에, 즉 밖에서 breakpoint를 걸었다. 이때도 이 포인터가 가리키는 구조체 내용을 좀 조회할 수 있었으면 좋겠는데 그게 Visual Studio IDE에서 안 돼서 답답했던 경우가 많다. 그렇다고 구차하게 소스 코드를 고쳐서 디버깅일 때에 한해서 감춰 놓은 내부 구조체 몸체 선언 include를 시키고 싶지도 않다.
특정 상황에 한해서 컴파일 때는 참고하지 않는 다른 소스 코드의 디버깅 정보를 가져오는 기능이 있으면 좋을 것 같다.

6. 나머지

(1) 컴파일러와 링커는 오늘날까지도 환경 변수라는 게 쓰이는 얼마 안 되는 분야이기도 하다. 환경 변수라는 게 명령줄에서 실행 파일을 자동으로 찾는 PATH, 그리고 컴파일러가 사용하는 기본 include 및 라이브러리 디렉터리... 이것 말고는 쓰이는 곳이 정말 드물지 않은가? 자체적인 환경 설정 파일 같은 게 동원될 법도 한데 컴파일러와 링커는 GUI 프로그램이 아니다 보니 좀 더 저수준이면서 실행되는 세션별로 사용자가 값을 더 간단하게 변경할 수도 있는 환경 변수를 대신 선택한 것 같다.

(2) 과거에 도스용 Turbo C/C++ 같은 물건은 굳이 프로젝트 파일을 안 만들어도 소스 하나만 단독으로 달랑 열어서는 곧장 빌드해서 돌려 볼 수 있었다. 그러나 요즘 개발툴들은 단순 텍스트 에디터 이상의 매우 복잡하고 방대한 물건이기 때문에 그렇게 할 수 없다. Hello world! 한 줄짜리 프로그램을 만들더라도 최소한의 프로젝트 세팅은 한 뒤에야 빌드와 디버깅이 가능하다.

(3) 그리고 요즘 개발툴들은 여러 소스 파일들을 한데 묶은 프로젝트로도 모자라서.. 프로젝트도 여러 개를 한데 묶은 '솔루션, workspace'라는 개념으로 운용된다는 것이 주지의 사실이다. 이 정도는 돼야 좀 규모 있는 소프트웨어를 원활히 개발 가능하기 때문이다.

(4) 컴터 프로그램 개발을 하다 보면.. 디버깅 로그가 실시간으로 뜨게 해 놓은 채로 디버기 프로그램을 구동하고 일정 주기로 결과를 확인할 때가 있다.
그런데 이때 프로그램이 출력하는 로그만 넣는 게 아니라, 사용자가 로그에다가 인위로 "=======" 같은 가로줄 같은 걸 즉석에서 추가할 수 있으면 좋겠다는 생각이 든다. 한 프로그램에서 동작 시험을 여러 번 할 때 로그의 영역을 하기 위해서이다.

(5) 앞으로는 "주 메모리에 로드되어 실행된 프로그램 / 하드디스크에 설치돼 있는 프로그램 / 원본 설치 패키지"라는 소프트웨어의 통상적인 3단계 구분이 더 모호해지고 단순화되지 않을까 생각된다.
일단 웹 프로그램은 설치라는 과정이 없는 게 확실하며, 메모리 계층에서 보조 기억장치와 주 메모리의 구분이 모호해지는 것도 이런 추세를 더욱 부채질할 테니 말이다.

Posted by 사무엘

2021/06/26 08:35 2021/06/26 08:35
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1903

프로그래밍에서 메모리를 가리키는 포인터라는 건.. 그 특성상 돌아가는 컴퓨터의 machine word와 크기가 동일하다. 하지만 현실에서 포인터(= 메모리 주소)를 구성하는 모든 비트가 골고루 쓰이는 일은 몹시 드물었다.

먼저, 컴퓨터의 실제 메모리 양이 포인터가 가리킬 수 있는 범위보다 훨씬 적다. Windows의 경우, 32비트 시절에는 user mode에서는 대부분의 경우 포인터의 상위 비트가 언제나 0이었던 것이 잘 알려져 있다(하위 2GB까지만 사용).
하물며 64비트는 공간이 커도 너무 크기 때문에 가상 메모리 관리 차원에서도 아직은 40~48비트까지만 사용한다. 상위의 무려 16비트가량이 쓰이지 않는다는 것이다. 램이 32GB여도 겨우 35비트면 충분하니까..

가난하고 배고프던 20세기 16비트 시절에는.. 반대로 포인터 하나만으로 겨우 몇백 KB~수 MB 남짓한 메모리도 한번에 다루지 못했다. 그래서 far 포인터니 huge 포인터니 별 삽질을 다 해야 했는데 그때에 비하면 지금은 격세지감이 따로 없다.

저렇게 상위 비트뿐만 아니라 하위 비트도 마찬가지이다. padding, align 같은 이유로 인해, 메모리 할당 함수의 포인터 리턴값이 홀수가 될 일은 일반적으로 없다. 아니, 겨우 2의 배수가 아니라 4나 8의 배수가 될 수도 있으며, 이 경우 하위 2~3개 비트도 0 이외의 값을 가질 일이 없게 된다.

그러니 포인터를 저장하는 공간에서 0 이외의 값이 들어올 일이 없는 비트에다가 자신만의 정보를 넣는 꼼수를 부리는 프로그램이 예로부터 줄곧 존재해 왔다.
이거 무슨 변태 같은 짓인가 싶지만.. 이제 막 32비트로 넘어가긴 했지만 아직 가정용 컴퓨터들의 평균적인 메모리 양이 수 MB대밖에 안 됐던 시절이 있었다. 이때는 메모리가 부족해서 하드디스크 스와핑이 일상이었다. RAM을 1바이트라도 더 아끼는 최적화가 필수였다.

가령, 다재다능한 자료구조인 빨강-검정 나무를 생각해 보자.
노드의 색깔을 나타내는 겨우 1비트짜리 정보를 위해서 굳이 bool 멤버를 추가하는 건 굉장한 낭비라는 생각이 들지 않는가? 단 1비트 때문에 구조체 패딩까지 감안하면 무려 2~4바이트에 달하는 공간이 매 노드마다 허비되기 때문이다.
안 그래도 노드의 내부엔 left/right 같은 딴 노드 포인터가 있을 것이고, 포인터 내부에 쓰이지 않는 1비트 공간이 있으면 거기에다 색깔 정보를 박아 넣고 싶은 생각이 들 수밖에 없다. 비트필드와 포인터의 union 써서 말이다.

물론, 그렇게 0으로만 채워지던 공간을 운영체제에서도 나중에 유의미하게 사용하기 시작하면.. 그 꼼수 프로그램은 재앙을 맞이하게 된다.
대표적인 예로 마소에서는 32비트 기준으로 사용자:커널이 통상적인 2GB:2GB가 아니라 3GB:1GB로 주소 공간을 분할하는 기능을 Windows에다가 추가했다.

이러면 사용자 모드의 포인터도 2GB가 넘는 영역에 접근할 수 있으며 최상위 비트가 1이 될 수 있다. 그런데 포인터의 최상위 비트를 자기 멋대로 사용하고 있는 프로그램은.. 뭐 메모리 뻑나고 죽을 수밖에 없다.
64비트 환경에서는 겨우 1비트가 아니라 상위 word 전체를 다른 용도로 전용해도 당장 이상이 없으며 이 추세가 앞으로 몇 년은 가지 싶다. 컴퓨터의 램이 256~512GB나 1테라까지 간다면 모를까..

요즘 컴퓨터야 메모리가 워낙 많고 풍족하니, 굳이 저런 꼼수를 동원하는 프로그램은 별로 없을 것이다.
하지만 저 때가 되면 또 꼼수 부리는 말썽꾸러기 프로그램과의 호환성 때문에 주소 공간을 옛날처럼 상위 16~32GB까지로 봉인하는 옵션 같은 게 또 등장할지도 모른다.;;; HIGH_DPI_AWARE처럼 LARGE_ADDRESS_AWARE 시즌 2 말이다.

여담이지만 Windows의 경우, 실행 파일은 시작 주소가 언제나 64KB의 배수 단위로 부여되기 때문에 HINSTANCE/HMODULE은 아래쪽은 무려 word 덩어리가 언제나 0이 된다. 이 특성을 이용해서 운영체제의 LoadLibraryEx 함수도 하위 몇 비트를 자기 마음대로 활용하기도 한다.

※ 나머지 메모

(1) unsigned 타입에 대해서 단항 연산자 -를 적용해서 -a 이런 값을 구하는 코드를 우연히 보고는 개인적으로 신박하다는 생각이 들었다. 흐음~ Visual C++의 경우 이건 원래 경고인데, 요즘 버전에서는 더 엄격하게 에러로 처리하는가 보다.
-a는 2의 보수의 특성상 ~a+1과(비트 not보다 1 크게) 완전히 동일한 효과를 내며, 앞에 0을 붙여서 이항 연산자로 만들어도 에러를 회피할 수 있다.

(2) ANSI C에서는 함수의 prototype을 선언할 때 매개변수 리스트에 타입만 써 넣고 이름을 빼먹으면 안 된다는 걸 최근에야 알게 됐다.
아니 도대체 왜..? 거기서 매개변수의 이름은 거의 잉여 옵션에 불과할 텐데.. void func(int);라고만 쓰면 틀리고 void func(int x);라고 아무 이름이라도 붙여야 된다는 것이다.
이건 먼 옛날에 C언어에서 void func(a) int a; 같은 구닥다리 문법이 쓰이던 시절의 잔재인 것 갈다.

Posted by 사무엘

2021/05/15 08:35 2021/05/15 08:35
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1887

1. 함수 명칭만으로 오버로딩 분간하기

C++에는 그 이름도 유명한 함수 오버로딩이라는 게 존재하기 때문에 어떤 함수를 이름만으로 유일하게 식별할 수 없다. 링크를 위한 심벌을 생성할 때는 자신이 받아들이는 인자들의 개수와 타입도 이름에다가 일일이 곁들여 줘야 동명이인(?) 함수들을 구분할 수 있다. 이 절차를 decoration이라고 한다.

그런데 바이너리 차원에서 함수 이름에 대한 구체적인 decoration 방식은 표준으로 정해진 것이 없어서 컴파일러마다 완전히 제각각이다. 이 때문에 동일한 타겟에다 동일한 포맷의 obj/lib(기껏해야 OMF 아니면 COFF..)라도 C++ 클래스 라이브러리는 컴파일러를 넘나드는 이식성이 크게 떨어진다.

C 단독이 거의 쓰이지 않는 오늘날까지도 유명 라이브러리들이 C언어 형태의 단순한 인터페이스를 고집하는 이유 중의 하나가 바로 이것이다. 아니면 그런 C 함수를 아주 얇게 감싸는(생성자, 소멸자, 오버로딩..) C++ wrapper 클래스를 만들더라도 하는 일이 정말 C 함수 호출밖에 없고, C++ 멤버 함수는 몽땅 인라이닝이 되게 만든다. 인라이닝이 됐다면 decoration이고 뭐고 걱정할 필요가 전혀 없어지기 때문이다.

그런데.. C++에서 함수를 (1) 실제로 호출하지 않고, (2) 저렇게 decoration 정보가 있는 link time도 아니면서 소스 코드 차원에서(= compile time) 오버로딩 동명이인 함수를 구분할 수는 없을까?

엥? (1)도 (2)도 아닌 애매한 상황은 C++에서 한동안 고려할 필요가 없었다. 그러나 2010년대부터 modern C++이 등장하고, 명칭만으로 type을 유추할 수 있는 auto와 decltype 키워드가 도입되면서 발생할 수 있게 됐다.

void foo() {}

라는 함수를 정의해 놓았다면

auto ptr = &foo;
decltype(&foo) ptr = ...;

이런 식으로 foo를 얼마든지 써먹을 수 있다. auto와 decltype이 자동으로 void (*)()이라는 타입을 유추해 내기 때문이다. 그런데 문제는..

void foo(int) {}

같은 overload를 더 만들었을 때이다.
그러면 이때부터 auto와 decltype에다가 foo를 언급할 수 없게 된다. 이 이름이 가리키는 함수의 prototype이 하나로 딱 떨어지지 않기 때문이다.

이에 대해 C++ 표준 스펙은 오버로딩된 함수 이름을 저기에다 집어넣는 것은 ill-formed라고만 지적하고 넘어간다. (☞ 링크)
이건 컴파일된 obj가 아니라 소스 코드 레벨이기 때문에 decorate된 저수준 명칭을 사용할 수도 없다. 어느 오버로드인지를 지목하는 문법은 딱히 마련하지 않고 넘어간 듯하다.

글쎄, 생각 같아서는 foo as_in () 내지 foo as_in (int) 같은 오버로딩 식별을 위한 토큰이 필요해 보인다.
파이썬이라면 모를까 C++에서 겨우 이런 용도를 위해 영단어 예약어를 도입하는 건 보기 좋지 않다. 그냥 foo 다음에 -> : []처럼.. 데이터 포인터가 아닌 함수 포인터라면 쓰일 일이 없는 기존 연산자 토큰을 늘어놓아서 이 함수의 인자들의 타입을 기술해 주면 될 것이다.

참고로, 함수의 포인터를 얻는 상황에서는 static_cast가 아주 유용하게 쓰인다.
void *p = static_cast<int (*)(int,int)>( func_ptr );

이렇게 해 주면 이 형변환은 func_ptr이 int 2개를 받고 int를 리턴하는 놈이 실제로 선언이나 정의돼 있을 때에만 성공한다. 그런 게 없으면 에러.. 그러니 안전하다. 흥미롭지 않은가? 형변환 연산자가 함수의 동명 오버로드를 분간할 때도 쓰인다니 말이다.
C-style cast나 reinterpret_cast는 그런 면모가 없고 무조건 될 것이다.

2. 복합 포인터 간의 더 세밀한 형변환 연산자

C/C++에서 void*라는 포인터 타입은 잘 알다시피 다른 아무 자료형을 가리키는 포인터를 받아서 대입될 수 있다. 공집합은 다른 모든 집합들의 부분집합이며, 1은 다른 모든 자연수의 약수인 것과 같은 이치이다.
malloc을 비롯해 메모리를 할당하는 함수들은 특정 자료형에 구애받지 않는 void*를 되돌린다. 그런데 이 포인터값을 함수의 리턴값이 아니라 함수 인자로 전달된 포인터가 가리키는 주소에다 받게 하려면 어떡해야 할까?

그럴 때는 별 수 없이 이중 포인터가 쓰이게 된다. 자주 보는 물건은 아니지만 Windows에서 COM 객체를 다루다 보면 QueryInterface 메소드, CoCreateInstance 함수 때문에 접하게 된다. COM에서는 함수 리턴값은 에러코드(정수값)를 되돌리고, 포인터는 인자로 전달된 주소를 통해 받기 때문이다.

여기서 우리는 문법 차원에서 약간의 어색함을 경험하게 된다.
void*는 int*, char* 등 다른 포인터 타입과 호환되지만 이중 포인터끼리는 그렇지 않다. 가령, void**와 int**, char** 따위는 호환되지 않는다.
상속 관계도 마찬가지이다. IUnknown**에다가 IUnknown의 파생 클래스의 포인터의 주소를 바로 넘겨줄 수는 없다.

단순 포인터는 한쪽은 호환되고, 역변환은 static_cast만 해 주면 된다. 그러나 이중 이상의 포인터는 가리키는 타입도 타입 그 자체가 아니라 그 타입의 포인터이다. 그렇다 보니 타입간의 아무런 계층 관계가 인정되지 않는다. 어느 방향으로든 얄짤없이 무식하게 reinterpret_cast만 써야 한다.

글쎄, 이런 일이 자주 발생하고 구분해 줄 필요가 꼭 있다면 pointer_cast 내지 reference_cast라고 해서.. 다중 포인터라도 참조 깊이가 동일하고 최종적으로 도달하는 타입이 서로 static_cast급으로 호환된다면 변환을 허용하는 형변환 연산자를 둘 법도 해 보인다. 저건 굳이 reinterpret_cast까지 사용해야 할 정도로 과격하고 위험해 보이지는 않기 때문이다.

하지만 저 상황만을 위해서 별도의 키워드까지 추가하는 건 좀 overkill 낭비로 보이니 그렇게 되지 않은 것 같다. 배열이야 필요에 따라 3차원 이상의 다차원 배열이 쓰일 수 있지만, 포인터는 본인도 20여 년에 달하는 프로그래밍 인생 이래로 3중 이상 깊이의 포인터를 사용할 일은 전혀 없었다. 즉, void ***p라든가, 이중 포인터에 대한 주소값 같은 것 말이다.

포인터라는 게 크게.. (1) 타 자료형에 대한 포인터, (2) 함수에 대한 포인터, (3) 구조체 및 클래스의 멤버에 대한 포인터라는 세 갈래로 나뉘는 것 같다. 물론, (1)~(3) 종류 불문하고 포인터를 가리키는 포인터는 자동으로 (1)에 속하게 된다. (2)와 (3)은 void*로 싸잡아 일컫는 것이 권장되지 않거나 원천적으로 가능하지 않다.

멤버 포인터의 포인터 내지 배열 같은 것은.. 잠깐 테스트를 해 보니 그래도 문법적인 한계나 typedef 땜빵 없이 이렇게 바로 선언해서 쓸 수 있긴 하다. 물론 멤버 포인터 자체도  쓰일 일이 극도로 드문데 하물며 그걸 또 가리키는 포인터는.. 삼중 이상의 포인터만큼이나 정말 레어템이다. 아래처럼 말이다.

class A {
public:
    void func(int x);
};

void (A::*pfn)(int) = &A::func;
void (A::**ppfn)(int) = &pfn;
void (A::*apfn[1])(int) = { &A::func };

A obj;
(obj.*pfn)(1);
(obj.**ppfn)(2);
(obj.*apfn[0])(3);

복잡한 포인터에서 세부 속성 간의 형변환은 비단 다중 포인터에만 존재하는 게 아니다. 함수 포인터에다가 인자의 개수와 calling convention 같은 건 완전히 일치하고, 일부 인자나 리턴값만이 void*와 char*처럼 미묘하게 다른 함수를 집어넣는 상황을 생각해 보자. 일반 함수뿐만 아니라 C++ 멤버 함수의 포인터도 해당된다.

이때도 지금 문법에서는 닥치고 C-style 또는 reinterpret_cast를 쓰는 수밖에 없다. 하지만 static_cast와 reinter_*의 중간 완충 역할을 해서 저럴 때만 유도리를 허용하는 연산자가 있다면 문법 차원에서 실수가 더 줄어들 수 있고 더 깔끔한 코드를 작성할 수 있다. 프로그래밍 언어에서 type theory의 심오함이 문득 느껴진다.

3. 복수 인터페이스의 구현체에서 IUnknown 베이스 얻기

C++로 COM 인터페이스를 지원하는 클래스를 구현하다 보면.. 다중 상속 기능을 이용하여 한 클래스에다가 2개 이상의 인터페이스를 한꺼번에 집어넣는 경우가 있다.
예를 들어 한 윈도우가 drag & drop을 양방향으로 지원해서 데이터를 밖으로 날릴 수도 있고 받을 수도 있다면, 그 윈도우를 나타내는 클래스(가령, CMyWnd)에다가 IDataObject, IDropSource와 IDropTarget를 몽땅 때려박는 게 편하다.

이렇게 하고 나면, 그 CMyWnd를 상대로 인터페이스들의 베이스인 IUnknown의 QueryInterface, AddRef, Release 메소드를 호출하는 것이야 아무 문제 없이 된다.
다중 상속의 특성상, CMyWnd를 IDataObject, IDropSource, IDropTarget 등으로 캐스트한 포인터 값은 제각각 달라질 수 있다. 왜냐하면 멤버 변수가 없는 인터페이스이더라도 상속을 하나 할 때마다 vtable 포인터의 크기 하나씩은 클래스에다가 차지하게 되기 때문이다.

하지만 이들의 vtable에서 IUnknown 파트는 모두 공통으로 CMyWnd가 구현한 동일한 QueryInterface, AddRef, Release 함수를 가리키게 된다. 이게 바로 마법의 비결이다.
단, 둘째, 셋째, n째 인터페이스들은 this 포인터 값을 살짝 보정한 뒤에 원래 함수를 호출하는 thunk가 추가된다. 마법이 공짜는 아닌 셈이다. 그래서 다중 상속에서는 내가 함수를 호출한 객체의 주소와, 해당 멤버 함수가 받은 this 포인터의 값이 일치하지 않을 수 있다.

그렇게 다중 상속에서 함수 호출과 this 보정 문제가 해결되었는데.. 정작 CMyWnd 오브젝트의 포인터를 IUnknown* 자체로 cast 하는 것은..??? 뜻밖에도 되지 않고 컴파일 에러가 난다. 암시적 자동 형변환은 물론이고, static_cast와 C-style cast도 통하지 않는다.
왜냐하면 얘는 2개 이상 여러 인터페이스를 구현했는데 어느 놈을 기준으로 삼아서 IUnknown으로 cast 해야 할지 알 수 없기 때문이다. 모호성이 존재한다는 것이다. 뜨악~

현실에서 어지간해서는 이런 일을 겪을 일이 거의 없다. 그냥 파생 클래스 구현체가 베이스 인터페이스의 완벽한 상위 호환이니, 어지간한 상황에서는 그냥 그 클래스를 쓰면 되지 굳이 베이스로 형변환을 할 일 자체가 없기 때문이다.

하지만 아무 인터페이스 오브젝트나 받아들여서 레퍼런스 카운트 관리만 한답시고 IUnknown을 인자로 받는 함수가 드물게 있을 수 있다. 그 함수에다가 이런 오브젝트를 덥석 넘겨주면 어느 베이스의 IUnknown을 골라야 할지 모르겠다는 태클에 걸린다. 저 인터페이스들이 IUnknown을 가상(virtual) 상속을 한 게 아니기 때문에 이 문제를 피해 갈 수 없다. 어차피 인터페이스에는 데이터 멤버도 없으니 아무거나 골라도 됨에도 불구하고 말이다.

그 클래스가 상속한 베이스들 중 가상 함수가 존재하는 제일 첫 놈이 IUnknown 기반의 인터페이스라면.. 그 클래스의 인스턴스의 포인터는 그대로 직통으로 IUnknown으로 형변환해도 된다. 하지만 이건 이게 C++의 문법 차원에서 안전이 보장될 수 없는 동작이기 때문에 컴파일 에러가 발생하는 것이다.

C-style cast는 static_*과 reinterpret_*의 중간 정도 위상을 차지하는 물건이며, 포인터 간의 형변환에서는 오히려 후자에 더 가까운 위치에 있다. 하지만 다중 상속에 대해서는 의외로 선 넘지 않는 안전 장치가 걸려 있는가 보다.
저 때 자기 자신을 베이스인 IUnknown으로 강제로 둔갑시키는 수단은 reinterpret_cast밖에 없다.
아니면!! 자신을 void*로 먼저 전환한 뒤에 그걸 IUnknown으로.. static_cast를 두 번 적용하면 된다. 물론 이것들은 다 at your own risk를 감수하고 해야 한다.

이 문제를 해결한답시고 CMyWnd에 대해서 무식하게 QueryInteface(IID_IUnknown, &obj)를 하는 건 너무 오버 같다.
뭐, 어느 베이스를 선택할지 static_cast<IDataObject*>(&obj) 이렇게 명시적으로 지정을 해 주면 모호성이 해소되어 C++ 차원에서 IUnknown으로 cast도 가능해진다.
하지만 이것도 언어 차원에서 더 깔끔하게 해결할 방법이 없는지, const 객체뿐만 아니라 베이스 인터페이스에 대해서도 select_any 같은 속성을 지정해 줄 수는 없을지 궁금하다.

참고로 이런 형변환이 일어나는 곳은 해당 객체 자체가 아니라 십중팔구 그 객체의 포인터들이다. 그러니 그 클래스에서 operator IUnknown*() { return static_cast<****>(this); } 이렇게 전용 형변환 연산자를 구비하는 것은.. 성능 오버헤드는 없지만 언어 문법 차원에서의 아주 깔끔한 해결책으로 보기는 어려워 보인다.;;;

Posted by 사무엘

2021/04/30 08:33 2021/04/30 08:33
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1882

1. 파이썬

요즘 프로그래밍 언어는 네이티브 코드+수동 메모리 관리(= 가상 머신이나 GC가 없는) 분야에서야 C++이 무섭게 발전하면서 약진하는 중이다. D나 Rust나 델파이 같은 나머지 네이티브 코드 언어 진영은 요즘 어찌 지내나 모르겠다.

거기서 양상이 살짝 바뀌어서 VM 기반의 언어로는 Java와 C#이 대표적이다. C#은 매우 뛰어난 언어이고 기반이 탄탄한 건 사실이지만 PC와 Windows의 밖에서는 과연 쓸 일이 얼마나 있나 모르겠다. 안드로이드와 iOS 모두 앱 개발용으로 권장하는 주력 언어가 코틀린, Swift 등 생소한 것으로 바뀌었는데, 지난 수 년 동안 기존 언어(Java, Objective C)의 점유율은 어찌 바뀌었는지 역시 궁금하다.

이보다 표현이 더 자유로운 동적 타입 언어 세계에서는 닥치고 JavaScript 아니면 파이썬이 지존 압권 깡패로 등극했다. 펄, 루비, 루아.. 등등 필요 없고 이걸로 다 물갈이돼 버렸다. 특히 파이썬은 교육용과 실무용이라는 두 영역에서 완벽하게 주류로 자리잡았다는 것이 대단하고 신기하다.

대학교들 CS101 프로그래밍 기초 코스에서 가르치는 언어도 C, Java를 거쳐 지금은 몽땅 파이썬이다. 교육용이 아니면 일부 특수한 분야 한정의 마이너 언어에 그쳤던 과거의 베이식이나 파스칼과는 매우 대조적인 점이다. 한편, JavaScript는 웹의 세계 공용어라는 독보적인 지위를 획득했고 말이다.

네이티브 코드인 C++, 가상 머신 기반인 Java, 동적 타입인 파이썬.. 이렇게 등급과 종류를 불문하고 언어들에 한때는 객체지향 패러다임이 들어가는 게 유행이었는데, 21세기에 들어서는 함수형 패러다임도 필수가 돼서 익명 함수(람다) 정도는 지원해 줘야 아쉽지 않은 지경이 돼 있다.

C/C++, Java 같은 언어에만 파묻혀 살다가 컴파일 에러와 런타임 에러의 구분이 없는 언어..
catch되지 않은 예외 같은 에러를 잡고 나니 다음으로 소스 코드의 스펠링 에러를 접할 수 있는 언어를 쓰는 느낌은 참 묘하다.
그래도 컴파일 없이 바로 실행한다는 게 심리적으로 참 부담없고 가벼운 느낌을 준다. 먼 옛날에 Basic 쓰던 시절 이래로 얼마 만에 다시 경험하는 느낌인지?

  • 나눗셈은 정수/정수라도 언제나 실수가 되는구나. 이건 C/C++ 계열이 아니라 베이식/파스칼에 더 가까운 이념이다. 그래도 '같지 않음'이 <>가 아니라 !=인 것은 C/C++ 영향이다.
  • 비트 연산자는 & |로 두고, 논리 연산자를 and or이라는 단어로 분리한 것은 나름 양 계열의 특성을 골고루 적절하게 수용한 디자인인 것 같다.
  • 삼항 연산자 A ? B:C를 B if A else C로 표현한 것은.. 우와;;;;
  • 함수에 인자를 전달할 때 값만 그냥 전하기도 하고 경우에 따라서 config=100 이렇게도 주는 건.. C/C+++ 스타일과 objective C 스타일을 모두 접하는 것 같다.
  • 문자열이나 리스트 같은 복합 자료형에다가 상수배 곱셈 연산을 해서 복제 뻥튀기를 시키는 것도 상당히 유용하다. 단, 이 경우 내부에 있는 복합 자료형들은 shallow copy만 된다. 제대로 deep copy를 하려면 list comprehension 같은 다른 기법으로 원소들을 하나하나 새로 생성해야 한다.
  • 여러 변수에다 한꺼번에 대입하기, 그리고 리스트 원소들을 연달아 함수 인자로 풀어넣기...;;;
  • 코딩을 하다 보면 특정 자료구조 내부의 원소들을 range-based for 문으로 순회함과 동시에, 각 원소별로 1씩 증가하는 인덱스 번호도 같이 돌리고 싶은 때가 많다. 이럴 때 파이썬은 for i, elem in enumerator(set)라고.. enumerator를 사용하면 저 기능을 곧장 구현할 수 있다.. 오, 이거 사이다 같은데?
  • []는 배열, {}는 dictionary. 의도한 건지는 모르겠지만 JSON 자료구조와 딱 정확하게 대응한다.
  • 문자열에 "" ''을 모두 사용 가능한 건 SQL 같다. 다만, 문자로 표현된 숫자 리터럴과의 구분이 없다 보니, 'a'와 97을 상호 변환하는 건 베이식이나 파스칼처럼 별도의 함수를 써야 한다.

2. 각 프로그래밍 언어별로 없어서 처음에 좀 놀랐던 것들

  • JSON: JavaScript라는 프로그래밍 언어의 문법을 채용했다면서 정작 자신은 코멘트를 넣는 부분이 없고 정수 리터럴에 16진수 표기용 접두사가 없다. 얘는 오로지 machine-generation만 생각했는가 보다.
  • Java: int 같은 primitive type을 함수에다 reference로 전달해서 swap 같은 걸 시킬 수 없다. 그리고 가상 머신 환경에서 큰 의미가 없긴 하지만 sizeof 연산자도 없다.
  • 파이썬 1: goto가 없는 건 Java도 마찬가지이지만.. switch-case도 없다. 파이썬은 들여쓰기 구문이 콜론으로 끝나는 언어인데, 정작 C/C++계열에서 라벨과 콜론을 사용하는 문법이 저 동네에서 존재하지 않는 셈이다. 넣어 달라는 제안이 과거에 있긴 했지만 문법적으로 난감해서 봉인됐다고 한다. 뭐, 그 대신 얘는 elif가 있다.
  • 파이썬 2: 그리고 파이썬은 명시적인 const 속성도 없는 것 같다. 튜플이 값의 불변을 보장하는 자료형이기 때문에 const 테이블 역할을 같이 담당한다.
  • 파스칼: 오리지널 문법에서는 임의의 크기의 동적 배열을 만들 수 없다. 참고로 베이식은 배열의 크기 조절은 자유이지만 포인터가 아예 존재하지 않다 보니 리스트 같은 재귀 구조의 복잡한 자료구조를 구현하는 것 자체가 원천 불가능이다.
  • 익명 함수: C++의 람다만 그런 건지는 모르겠지만, 자기 자신을 간단히 가리키는 키워드가 없고 재귀호출을 구현할 수 없다. 그나마 구현했다는 것들은 다 주변의 다른 functor 등 갖가지 편법을 동원해서 매우 힘들게 억지로 구현한 것들이다.

사실, C/C++의 for문은 while문과 거의 동치일 정도로 조건 검사 지향적이고 range-based for는 21세기가 돼서야 도입됐다. 그러나 파이썬의 for문은 훨씬 더 range 내지 iterator 지향적이다.

그리고 베이식 같은 언어는 switch/case가 거의 if문의 연장선일 정도로 범위 지정도 되고 쓰임이 유연하지만.. C/C++의 switch/case는 그보다 제약이 심하다. 그 대신 그 제약을 이용해서 컴파일러가 최적화를 할 여지가 더 있다. (가령, 조건 검사 대신 테이블 오프셋 참조로..)

3. 언어 문법 차원에서의 지원

20여 년 전 먼 옛날에 스타크래프트 경기 중계방송이란 게 처음으로 행해지던 극초창기엔 경기 운영 노하우가 부족해서 이런 일이 있었다고 한다.
경기를 하는 선수 말고 화면 중계를 위한 옵저버도 게임에 join을 해야 하는데, 자기 기지는 없이 남들 시야 눈팅만 하는 상태로 참여하는 방법을 몰랐던 것이다.

그러니 그때 옵저버는 테란을 골라서 들어갔다. 자기 커맨드센터는 띄워서 맵 구석 모서리에 안 보이게 처박아 놓고, SCV 4기는 서로 공격시켜서 없앴다. 이런 궁색한 삽질을 해서 자기 존재를 최대한 없애 버린 뒤 선수들의 화면을 중계했던 것이다.

물론, 옵저버의 이런 자폭 플레이는 경기 시작 직후, 카메라가 잠시 각 선수들의 개인 화면을 비추고 있는 동안 최대한 잽싸게 행해졌다. 한편으로 선수들 역시 옵저버에게 자기 시야를 공개하는 설정을 매번 수동으로 해 줘야 했다.
선수가 옵저버의 커맨드센터를 고의나 실수로 부숴서 옵저버를 엘리시켜 버리는 건.. 그건 경기 진행 방해이며 규정상 거의 반칙 몰수패 사유가 됐을 것이다.;;

그러다가 잘 알다시피 경기용 맵은 특수하게 트리거를 조작해서 옵저버를 위한 전용 자리가 있는 "유즈맵, 커스텀 맵" 형태로 만들어지고 쓰이게 되었다. 이제 옵저버의 일꾼을 제거하고 커맨드센터를 치우는 삽질을 할 필요가 없어진 것이다.
하지만 경기 자체는 다른 특이 사항이 전혀 없고 건물 짓고 유닛 뽑아서 적 진영을 부수는 것밖에 없는데 매번 유즈맵을 쓰는 건 번거로웠다. 스타 프로그램 차원에서 일반 맵에다가 옵저버 참관 기능을 지원하는 게 제일 이상적이고 바람직했다.

결국 옵저버 참관 기능은 먼 훗날 스타의 1.18 패치에서 정식으로 도입됐다. 지난 1.08 패치에서 리플레이 기능이 추가된 것만큼이나 참신한 기능이다.
특히 이 참관 기능은 각 선수들의 개인 화면과 동급으로 진영별 자원 수, 생산· 연구 건물들의 내부 진행 상태까지 모두 볼 수 있어서 매우 편리하다. 과거의 유즈맵 옵저버로는 그런 게 가능하지 않았기 때문에 선수 개인 화면의 모습을 직접 봐야 했다.

이렇게 과거에 꼼수로 구현하던 기능들이 훗날 정식으로 가능해진 것의 예로는 C++ 프로그래밍이 떠오른다.
일례로, 복사나 대입이 가능하지 않은 클래스를 만들기 위해서 복사 생성자나 대입 연산자를 private에다가 미구현 상태로 박아 넣는 꼼수가 동원됐지만.. C++14부터는 = delete라는 더 완전하고 깔끔한 문법이 언어 차원에서 추가됐다.

Posted by 사무엘

2020/08/03 19:31 2020/08/03 19:31
,
Response
No Trackback , a comment
RSS :
http://moogi.new21.org/tc/rss/response/1780

1. 가상 함수 테이블을 강제로 없애기

C++에서는 가상 함수가 하나 이상 존재하는 클래스는 그렇지 않은 클래스와 좀 다른 취급을 받게 된다. 자기가 새로 선언한 것뿐만 아니라 기반 클래스로부터 상속받은 가상 함수까지 포함해서 말이다.
가상 함수가 존재하는 모든 클래스들은 각각 가상 함수 포인터 테이블(v-table)이라는 일종의 global 배열을 할당받으며, 이를 가리키는 포인터를 멤버로 갖게 되고 생성자에서 그 포인터의 값이 초기화된다.

클래스에 온갖 파생 클래스와 가상 함수들이 추가되고 상속 단계가 복잡해질수록, 이 테이블들의 개수와 크기도 무시 못 할 비중을 차지하게 된다.
A라는 클래스에 가상 함수가 5개 있으면 A의 v-table은 크기가 5이다. 그런데 A로부터 상속받은 클래스 B가 또 가상 함수를 3개 추가한다면 B의 v-table의 크기는 5+3 = 8이 된다. 그런 식이다. 다중· 가상 상속 같은 것까지 자연스럽게 지원하려면 B의 v-table에도 A의 것이 고스란히 몽땅 중복 등재돼 있어야 한다.

그런데 문제는 순수 가상 함수가 들어가 있어서 단독으로 인스턴스가 생성될 일이 전혀 없는 클래스에 대해서도 일단은 동작의 일관성을 보장하기 위해 v-table이 잉여스럽게 따로 생성된다는 것이다.
왜냐하면 B라는 클래스의 생성자는 그 정의상 기반 클래스 A의 생성자를 반드시 호출하게 돼 있고, A의 생성자가 하는 일 중 하나는 A에 대한 고유한 v-table을 참조하는 것이기 때문이다.

어차피 값이 거의 전부 다 NULL이고 실제로 쓰일 일도 없는 v-table이 쓸데없이 생성되는 일을 막기 위해, Visual C++에는 __declspec(novtable)이라는 비표준 확장 속성이 도입되었다. 특히 COM이라는 규격을 C++ 언어와 바이너리 차원에서 연계하려다 보니 이 기능이 없어서는 도저히 안 되는 지경이 되었다.

굳이 순수 가상 함수가 없더라도, 어쨌든 반드시 파생 클래스 형태로만 쓰이지 그 자체가 인스턴스 형태로 선언될 일이 없는 클래스라면 __declspec(novtable)를 지정해 주는 게 메모리 절약 측면에서 좋다. 날개셋 한글 입력기의 경우 이걸 적용해 주니 ngs3.dll의 크기가 수 KB 남짓 줄어드는 효과가 있었다.

특히 데이터 멤버 없고 순수 가상 함수밖에 없는 인터페이스라면 이거 지정이 무조건 필수이다. 이래서 후대의 객체지향 언어들은 구조체(생성자 소멸자 가상 함수 없는 POD) vs 클래스뿐만 아니라, 일반 클래스 vs interface(추상 클래스)도 언어 차원에서 구분한다는 게 느껴졌다. 이게 단순히 외형이나 기분상의 구분만을 의미하지 않는다.

파생 클래스 쪽의 초기화가 덜 된 오브젝트에 대해서 순수 가상 함수를 호출해 버리면 보통은 pure virtual function call이라는 C++ 예외가 발생한다. 그런데 __declspec(novtable)가 지정된 오브젝트에 대해서 가상 함수를 호출해 버리면 저런 high-level 예외가 아니라 그냥 NULL 포인터 접근에 준하는 평범한 access violation 에러가 나면서 프로그램이 죽게 된다.

2. 가상 함수 테이블의 메모리 오버헤드

가상 함수 테이블 얘기를 계속하겠다.
B가 가상 함수가 100개 있는 A라는 클래스를 상속받아서 자신만의 가상 함수를 새로 만드는 것 없이, A의 함수 중 딱 한두 개만 override를 했더라도.. 컴파일러는 내용이 한두 개밖에 차이가 나지 않고 거의 동일한 원소 100개짜리 함수 포인터 배열을 또 하나 만들게 된다. B의 모든 인스턴스들이 한데 공유하는 가상 함수 포인터 테이블 말이다. 일종의 copy-on-modify와 비슷한 메커니즘이다.

그렇게 해야만 A건 B건 오브젝트 종류를 불문하고 가상 함수의 주소를 배열 오프셋만으로 O(1) 시간 만에 곧장 얻어 와서 호출을 할 수 있기 때문이다.
가상 함수 호출은 그렇잖아도 오버헤드가 일반 함수 호출보다 훨씬 더 큰데, 메모리를 아낀답시고 주소를 얻는 것조차 클래스 계층이나 함수 테이블을 뒤지는 방식이어서는 곤란하다.

하지만 이런 내부 디테일과 오버헤드를 생각하면 클래스를 아무 부담 없이 막 상속받기가 좀 부담스러워진다. 뭐, 요즘처럼 컴퓨터에 메모리가 풍족한 시대에 무슨 198, 90년대 같은 메모리 구두쇠 쫌생이처럼 코딩을 할 필요는 없지만, 그래도 같은 값이면 컴퓨터의 자원을 아껴 쓰는 게 프로그래머의 미덕 아니겠는가?

그렇다고 언어의 스펙을 바꿔 버릴 수는 없으니, Windows용 C++ 라이브러리인 MFC는 저런 가상 함수 테이블 오버헤드를 줄이기 위해, CWnd 클래스의 Windows 메시지 handler 함수는 가상 함수 대신 pointer-to-member 기반의 message map으로 구현한 것으로 잘 알려져 있다.

얘는 사용자가 실제로 overriding을 한 함수의 개수만큼만 테이블의 크기가 커지니 공간 사용은 효율적이다. 그러나 메시지에 대응하는 함수를 찾는 일은 매번 테이블을 선형 검색을 해야 하기 때문에 시간 복잡도가 O(n)으로 커진다.
이 부분은 정말 밥 먹듯이 자주 호출되는 부분이고 목숨 걸고 성능을 최적화해야 하는지라, MFC 소스에서도 극히 이례적으로 인라인 어셈블리가 사용되어 있다~! (wincore.cpp의 AfxFindMessageEntry 함수)

객체지향을 구현하기 위해서 이런 시간-공간 tradeoff를 따져 봐야 하니, C++보다 더 유연한 디자인을 추구한 Objective C 같은 언어는 메시지를 아예 문자열로 식별하게 했다(selector). 이름으로부터 실제 주소 매핑은 물론 hash 기반이고..

C++은 배열 오프셋 기반이어서 당장 성능 자체는 제일 좋으나, 클래스에서 뭐가 바뀌었다 싶으면 몽땅 재컴파일 해야 한다. 그리고 그런 경직된 체계에서 다중/가상 상속과 멤버 함수 포인터까지 구현하려다 보니 디자인에 무리수가 많이 들어갔고, 그 뒷감당은 결국 컴파일러 제조사들이 비표준 확장을 도입해서 땜빵하는 촌극이 벌어졌다.

그에 비해 Objective C는 신호등 교차로 대신 회전 교차로, 수동 변속기 대신 아예 자동 변속기 같은 접근을 한 셈인데.. C++ 같은 C의 이념 적당히 절충이 아니라 객체지향 언어라는 이론만 생각해 보면 저런 접근 방식도 충분히 가능은 하겠다는 생각이 든다.

3. 캐사기 auto

람다 함수가 최초로 도입된 C++0x 시절에 곧장 가능했던 것 같지는 않다만..
요즘 람다는 인자와 리턴값의 타입으로 auto를 지정할 수 있다.. >_<
auto에서 또 auto가 가능하다니? 그것도 C++ 같은 언어에서.. 이건 좀 꽤 사악한 기능인 것 같다.

auto Func = [](auto x) -> auto { return x * 2; };
printf("%f\n", Func(1.4));
printf("%d\n", Func(25));

람다가 일반 C++ 함수 같은 오버로딩이나 템플릿을 지원하지는 않으나, 저기서는 auto가 템플릿 인자 역할을 하는 거나 마찬가지이다.
생긴 건 함수와 비슷하지만 캡처가 지원되고 호출 규약 제약 없고 저런 것까지 가능하니.. 기존의 함수와는 형태가 얼마나 다른지 알 수 있다.

그나저나 람다 함수는 함수 자기 자신을 지칭해서 재귀 호출을 하는 방법은 여전히 없는지? this처럼 자기 함수 자신을 가리키는 예약어가 필요하지 않을까 싶다. __self??
그리고 사실은 C++도 기반 클래스를 지칭하는 __super 같은 키워드도 있어야 할 듯하다.

4. export의 재탄생

C++에서 완전 존재감 없는 잉여로 전락했던 auto가 10여 년 전 2010년대부터는 잘 알다시피 언어의 패러다임의 변화를 주도하는 거물로 환골탈태했다.
그것처럼.. 지난 2000년대에 비현실적인 흑역사 키워드로 전락하고 봉인됐던 키워드 export가.. 2020년대부터는 모듈 선언이라는 완전히 다른 용도로 다시 쓰이게 될 것으로 보인다. 하긴 import, export는 그 대상이 무엇이 됐건 프로그래밍에서는 굉장히 즐겨 쓰일 만한 의미의 동사이긴 하다.

지난 수십 년을 C처럼 원시적인 텍스트 include와 라이브러리 링킹에 의존했던 C++에 파스칼이나 D처럼 신속하게 빌드 가능한 모듈, 유닛이 도입되려는가 궁금하다.
그럼 이제 C/C++에서 제일 존재감 없는 잉여 키워드로는 signed만 남는 건지?

C++이 이 정도까지 변하고 있는데 #pragma once는 좀 언어 차원에서 정식으로 표준으로 수용할 생각이 없는지 궁금하다.
이제 플랫폼을 불문하고 지원하지 않는 컴파일러가 거의 없는 사실상의 표준이며, 실용적으로도 꼭 필요한 기능인데 말이다.

5. 템플릿의 prtial specialization

C++ 템플릿에는 template<typename X> class A처럼 말 그대로 템플릿을 선언하는 게 있는가 하면, template<> class A<XXX> 처럼 특정 타입에 대해 specialization을 하는 문법이 있다. 둘의 형태를 주목하기 바란다.
그런데 C++03즈음에서는 partial specialization이라고 해서 단일 타입이 아니라 일정 조건을 만족하는 템플릿 인자에 대해서는 몽땅 이 specialization을 사용하도록 하는 기능이 추가되었다. 가령 X가 포인터 타입이라든가, 템플릿 인자 A,B를 받는데 B가 A 내부의 pointer-to-member 타입이라거나 할 때 말이다.

이런 specialization에서는 template<typename X> class A<X*> 내지 A<X, X::*> 같은 식으로 template과 A에 템플릿 인자가 모두 들어온다.
개인적으로 이런 기능을 사용해 본 적은 없다. 특정 상황에서 편리한 기능이 될 수는 있겠지만.. 컴파일러를 만드는 건 더욱 어려워질 것 같다..;; 저 시절에 이런 기능과 함께 export까지 제안됐었다니 참 가관이다.

6. C++ 코딩 중에 주의해야 할 점

예전 글에서 언급했던 것들이지만 다시 한데 정리해 보았다.
참고로, 클래스의 상속 관계에서 부모/자식(parent/child), 기반/파생(base/derived)은 서로 기능적으로 아무 차이 없이 기분 따라 섞여서 쓰인다.
인자와 매개변수도(argument/parameter) 그냥 섞여 쓰이는 것 같다.

(1) C++에서는 오버로딩과 오버라이딩이 동시에 같이 자동 지원되지는 않는다.
가령, 한 클래스에서 foo라는 같은 이름으로 void foo()와 void foo(int x)를 모두 정의하는 건 당연히 가능하다(오버로딩).
하지만 둘 중 하나를(가령, 후자) 가상 함수로 지정해서 파생 클래스에서 그놈을 오버라이딩 했다면.. 파생 클래스에서는 오버라이딩 되지 않은 다른 '오버로드' 버전은 자동 지정이 되지 않는다. 이건 혼동을 피하기 위해 C++ 언어의 스펙 차원에서 일부러 그렇게 설계된 것이다.

파생 클래스에서 전자를 사용하려면 기반 클래스의 이름을 거명해서 기반::foo() 이런 식으로 언급하거나..
아니면 파생 클래스의 선언부에서 using 기반::foo; 라고 명시적으로 선언을 해 주면 된다. 그러면 foo만으로 기반 클래스의 두 함수를 곧장 접근할 수 있게 된다. using에 이런 활용 가능성도 있었다니~!

(2) 클래스 객체의 생성 단계에서 멤버들이 초기화되는 순서는 헤더에서 선언된 순서이지, 생성자 함수의 이니셜라이저 목록에 등장하는 순서가 아니다. 그렇기 때문에..

class Bar {
    int x,y;
public:
    Bar(int n): y(n), x(y+1) {}
};

위의 코드에서 x는 제대로 초기화되지 않는다. x(y+1)이 y(n)보다 먼저 실행되기 때문이다.
이건 방대하고 복잡한 클래스를 구현할 때 정말 쉽게 간과하고 실수할 수 있는 특성인데 컴파일러가 경고라도 해 줘야 되지 않나 싶다. 함수 안의 지역변수에서 "초기화되지 않은 변수 거시기를 사용했습니다" 경고를 띄우는 것과 비슷한 로직으로 별로 어렵지 않게 구현할 수 있을 텐데..??

(3) 생성자와 소멸자 함수에서는 자기 객체에 대한 가상 함수를 호출하지 말아야 한다. 비록 기반 클래스의 생성자 및 소멸자가 파생 클래스의 생성자 및 소멸자의 실행 과정에서 같이 호출되겠지만.. 기반 클래스는 자기 객체가 진짜로 기반인지 아니면 상속된 파생 클래스로부터 호출된 것인지를 전혀 알 수 없으며, 알려고 하지도 말아야 한다. 그냥 기반 클래스 자신의 일만 하면 된다.

이는 프로그래머에게 불편을 끼치는 규제가 아니라 언어의 컨셉이 그러하기 때문이다. 생성자와 소멸자는 Windows 메시지로 치면 그냥 WM_CREATE/DESTROY가 아니라 NC 버전이다. (WM_NCDESTROY는 자식 창들이 몽땅 다 사라지고 없는 상태에서 호출) 그리고 복잡한 처리를 절대로 해서는 안 되는 DllMain 함수와도 같다.

기반 생성자는 이 객체가 파생 클래스로서의 초기화가 되기 전에 먼저 호출되고, 기반 소멸자는 파생 클래스 부분이 소멸된 뒤에 맨 나중에 호출된다. 그러니 파생 클래스가 어떠한지를 따지는 것은 전혀 무의미한 것이다.
이건 위의 2번과 마찬가지로 초기화의 순서와 관련해서 저지르기 쉬운 실수이다. 2번은 멤버들의 초기화 순서이고(수평??) 3번은 상속 계층에서의 초기화 순서이다(수직??).

소멸자 자체는 반드시 가상 함수 형태로 선언해야 하지만 소멸자 내부에서 또 다형성을 기대하지는 말아야 한다는 것이 아이러니이다.

(4) 멤버 함수 포인터로 가상 함수의 오버라이딩을 제어할 수는 없다.
한 함수 포인터로 기반 클래스의 함수 내지 파생 클래스의 오버라이딩 함수 중 원하는 것의 주소를 저장하고, ptr의 값(정확히는 ptr이 가리키는 vtbl의 값)과 무관하게 ptr에 대해 원하는 함수를 강제 호출하는 것은 가능하지 않다.

그런 시도를 했을 때 함수 포인터에 저장되는 것은, 그저 ptr->vtbl에 매핑된 버전의 함수를 따로 호출하는 역할을 수행하는 thunk뿐이다.
그러니 멤버 함수 포인터를 동원한다 하더라도 vtbl을 거친 가상 함수 본연의 동작을 변형할 수는 없다.

Posted by 사무엘

2020/07/17 08:35 2020/07/17 08:35
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1774

« Previous : 1 : 2 : 3 : 4 : 5 : ... 8 : Next »

블로그 이미지

그런즉 이제 애호박, 단호박, 늙은호박 이 셋은 항상 있으나, 그 중에 제일은 늙은호박이니라.

- 사무엘

Archives

Authors

  1. 사무엘

Calendar

«   2023/03   »
      1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31  

Site Stats

Total hits:
2078306
Today:
373
Yesterday:
1062