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

오랜만에 또 C/C++ 문법 잡생각들을 늘어놓아 본다.

1. elaborated type specifier

C에서는 struct, enum, union 타입의 변수를 지정하려면 말 그대로 저 '종류' 명칭을 먼저 지정하고 나서 타입 명칭을 명시해야 했다. 종류 명칭을 생략하고 타입 명칭만으로 해당 종류를 나타내려면 C에서는 typedef를 번거롭게 해 줘야 했다.
그래서 C 시절에는 typedef struct _XXX { ... } XXX; 이런 두벌일이 관행이었다. struct _XXX라고 하든가, XXX라고 하든가 둘 중 하나다.

그러던 게 C++에서는 class라는 종류가 또 추가되었으며, 타입을 선언할 때 종류 명칭을 생략해도 되게 바뀌었다. struct XXX { ... }; 만 해도 XXX를 단독으로 쓸 수 있는 셈이다.
종류 명칭 지정은 required가 아니라 optional이 된 건데.. 허나, C++에서도 종류 명칭을 반드시 지정해야 할 때가 있다. 이런 full 명칭을 "elaborated type specifier"이라고 부르는데, 이게 필요한 상황은 바로 타입 명칭과 변수 명칭이 겹칠 때이다.

굉장히 의외이고 사실 권장되지 않는 관행이기도 하지만, C/C++에서는 기존 타입명과 동일한 명칭으로 변수를 선언하는 게 가능하다. (int, float 같은 built-in 타입 예약어는 당연히 제외)
ABC라는 클래스가 있다면 ABC ABC;라고.. ABC라는 이름의 객체/변수를 그대로 선언할 수 있다는 것이다. '야마토 급 전함 야마토'처럼 말이다.

두 클래스 A, B가 있고 앞에서 A B; 라고 B라는 변수를 선점해 버렸다고 치자.
이때 나중에 B라는 클래스의 인스턴스를 또 선언하고 싶다면 그때는 class B 뭐시기.. 이렇게 명시함으로써 이 B는 변수가 아닌 타입 명칭임을 알려줄 수 있다. A라는 클래스 소속의 변수 B, B라는 클래스 소속의 변수 A라고 상호 참조시키는 건 불가능하지 않으나 너무 사악해 보인다. -_-;;

전역변수와 지역변수가 이름이 겹칠 때 구분을 위해 :: 연산자를 사용한다면(C++ 한정), 변수명과 타입명이 겹칠 때 저런 종류 지정자가 쓰인다는 것이다.
내 개인적으로는 저 때야말로 typename 키워드도 사용 가능해야 하지 않나 생각하는데.. 그건 허용되지 않는 것 같다. ㄲㄲㄲㄲ typename과 class가 혼용 가능한(interchangable) 곳은 템플릿 인자뿐이다.

그 반면, 저기서는 struct와 class가 혼용 가능하다. 즉, class A라고 선언해 놓고는 elaborated type specifier로 struct A라고 쓰는 건 가벼운 경고 하나만 나오고 허용이다. 흥미롭지 않은지? =_=;; typename은 템플릿 바깥에서 범용적인 elaborated type specifier로서는 아직 접점이 없는 셈이다.

아울러, class는 자체적인 scope도 생성하는 역할을 한다. 그래서 :: 연산자에 잘못된 명칭이 지정됐을 때의 컴파일 에러는 "XXXX는 class 또는 namespace의 명칭이 아닙니다"이다. 요럴 때는 class가 말 그대로 namespace와 엮인다.
"class vs struct / typename / namespace"라니.. 이것도 흥미로운 점이다.

하긴, 변수명과 타입명이 겹치는 게 가능하니까 망정이지, 겹칠 수가 없다면 C 라이브러리의 struct tm (time.h)은 당장 이름이 바뀌어야 했을 것이다. 너무 짧고 겹치기 쉽고 성의 없게 만들어진 명칭이다. -_-;;

2. 정수형의 다양한 alias들

C/C++은 boolean 타입조차 없이 전부 int로 퉁치는 정수 덕후였다. 하지만 세월이 흐르면서 type-safety에 대한 필요성이 부각되었고, 용도에 따라 다음과 같은 alias 타입들이 등장해서 쓰이게 됐다.

(1) wchar_t (문자열): 유니코드 때문에 등장했고 얘 자체는 언어 표준으로 등극했다. wcslen, wcscpy 함수라든가, L"" 리터럴까지..
하지만 문자의 크기가 플랫폼별로 2바이트 내지 4바이트로 심하게 파편화됐다. 이 때문에 코드의 이식성을 저해하고 프로그래머들에게 큰 혼란을 끼치게 됐다.
결국 직접적인 크기를 명시하는 char16_t, char32_t가 나중에 일일이 추가됐다. 하지만 이것도 각 타입별 함수라든가 리터럴의 표기 방법, 심지어 % 문자열의 형식이 플랫폼마다 완전히 통일돼 있지 않다. 이식성 문제가 완전히 해결되지는 않았다는 뜻이다.

참고로 얘들은 다 built-in type이며, 기존 부호 없는 정수형의 단순 typedef가 아니다. 가령, char16_t의 포인터는 unsigned short의 포인터와 호환되지 않는다.
그리고 char이야 플랫폼 불문하고 무조건 1바이트라는 게 언어 스펙 차원에서 정의돼 있으니 char8_t를 또 만들 필요는 없다. 하지만 1바이트 문자열을 가리키는 char*는 처음부터 부호 없는 정수형으로 만들었으면 깔끔했을 텐데 하는 아쉬움이 좀 있다.

(2) ssize_t size_t (컴퓨터 비트 수): charXX_t처럼 일반 정수형도 크기를 명시한 intXX_t, uintXX_t 같은 게 도입됐는데, 얘들은 charXX_t와 달리 그냥 typedef이다.
그리고 64비트에서는 int와 long의 크기가 플랫폼별로 파편화돼 버린 관계로, 어디서나 포인터 크기와 동일함이 보장되는 정수형이 따로 만들어졌다. size_t라든가 intptr_t, uintptr_t, ptrdiff_t 말이다.
int를 4바이트로 유지시킨 건 그렇다 쳐도, long까지 32비트 4바이트로 굳힌 플랫폼은 Windows가 유일하다. 하위 호환성에 정말 목숨을 건 결정이다.

(3) time_t (미래 시간): 얘는 문자열이나 컴퓨터와 직접적인 관계는 없지만.. 그래도 21세기보다 훨씬 더 먼 미래를 표현하기 위해서 64비트로 확장되었다. time_t가 32비트이던 시절 기준으로 빌드된 구닥다리 프로그램들은 15년쯤 뒤 2038년 이후부터는 제대로 쓰기가 어려워질 것이다.
참고로 얘는 언제나 부호 "있는" 정수로 정의된다. 시각뿐만 아니라 두 시각의 차인 '시간'을 표현할 때도 쓰이기 때문이다. 과거와 미래를 모두 분간하려면 당연히 부호가 필요하다.

이런 숫자 alias들은 %문자와는 영 어울리지 않는다는 걸 알 수 있다. 저 typedef의 유동적인 비트수에 맞게 printf/scanf의 % 문자가 모든 플랫폼에 맞게 바뀌게 하려면... % 리터럴도 #define 해 가면서 바꾸면서 정말 지저분한 짓을 해야 된다. %ls인지 %S인지..?? %Id인지 %lld인지 %I64d인지.. 알 게 뭔가?

물론 값을 출력할 때는 모든 가변인자들이 intptr_t 크기로 promote되기 때문에 상황이 조금은 단순해진다. 하지만 입력을 받을 때라든가 32비트 플랫폼에서 64비트 값을 다룰 때는 역시 % 문자와 실제 변수 짝을 조심해서 대응시켜야 한다. 이러느니 C++ stream을 쓰고 말지.. =_=;;
그래도 %문자를 쓰는 게 다국어 지원 localize 관점에서는 취급이 아주 편리하다는 장점도 있는데 말이다. 차라리 독자적으로 % 문자 해석기를 만들기라도 해야 하나 싶다.

3. <=> 연산자

C/C++엔 ? : 이라고 유일하게 3개의 피연산자를 받는 독특한 연산자가 있다. if else문을 연산식 하나에다 박아 넣은 것이고, 오버로딩이 되지 않는다. 얘는 그냥 if else문만큼이나 C/C++의 문법처럼 취급되기 때문이다.
그런데, C++20에서는 단일 토큰으로서 길이가 3자나 되면서 연산 결과도 boolean 2종류가 아니라 '3종류'인 참 독특한 연산자가 추가되었다. 바로 <=> ... a <=> b는 a와 b의 대소 관계에 따라 1 0 -1 중 하나를 되돌린다. (실제로는 정확하게 정수형이 아니라 저 세 종류를 나타내는 comparision 객체 타입)
쉽게 말해 a, b가 문자열이라면 이 연산자의 결과는 strcmp 함수의 결과와 같다.

연산식에서 이 연산자가 당장 막 쓰이지는 않을 수 있다. 그러나 어떤 클래스를 구현할 때 이 연산자는 굉장히 유용하게 쓰일 것 같다. 얘는 온갖 자잘한 비교 연산자들의 상위 호환이기 때문이다.
<=> 연산자 하나만 오버로딩 해 놓으면 > < >= <= == != 을 모두 유추할 수 있다. a==b는 a<=>b == 0 이렇게 말이다.

이 연산자가 지원되는 클래스는 Java로 치면 Comparable 인터페이스를 받아서 CompareTo 메소드를 구현한 거나 마찬가지일 것이다.
C의 사고방식이라면 이 함수의 리턴값은 그냥 int이겠지만.. 얘는 C++의 이념이 가미됐다 보니 built-in 연산자의 리턴 타입이 언어 차원에서 따로 정의돼 있다.

Visual C++에서도 최신 C++20 표준 문법 옵션을 켜 주면 바로 써 볼 수 있다.
외국에서는 <=> 가 무슨 우주선(!!!!)처럼 생겼다면서 spaceship operator이라는 애칭으로 불리는가 보다.
10여 년 전엔 R-value 참조자 &&가 아주 참신하게 느껴졌는데 지금은 쟤가 비슷하게 참신하게 느껴진다.

4. 나머지 C

(1) 비트필드에 배열이 지원됐으면 좋겠다는 생각을 하는데.. 5비트씩 n개 같은 식으로 말이다. 이건 너무 욕심 부린 걸까..?? ㅎㅎ
뭐, 컴파일러의 입장에서 코드를 생성하는 게 힘들 수는 있지만.. 그래도 불가능하지는 않을 텐데 말이다.
아키텍처에 따라서 멤버들 방향 지정을 자동화하는 것과 더불어 개인적으로 비트필드에 바라는 사항이다.

(2) 배열의 원소 개수를 구하는 arraysize, 그리고 배열에서 특정 멤버의 오프셋을 구하는 offsetof
이거는 언어의 기본 문법과 연산자만으로 구현 가능하기 때문에 딱히 예약어로 지정돼 있지는 않다.
하지만 최소한 표준 라이브러리에 채택돼서 표준 헤더에서 제공할 만은 해 보인다. 특히 arraysize의 경우, C에서는 그냥 x/x[0] 같은 매크로로 구현되겠지만 C++에서는 더 type-safe한 인라인 템플릿 함수로 제공되면 될 것이다.

(3) C에는 자기 번역 단위의 밖으로 노출되지 않는 static 변수와 함수가 C++ 사고방식으로 치면 private 멤버와 얼추 비슷한 지위이다.
static 함수가 한 소스 파일 안에서 선언되고 참조(= 호출)도 됐는데 그 함수의 몸체가 정의돼 있지 않으면?? 이건 링크 에러가 아니라 해당 번역 단위에 대한 컴파일 에러로 처리된다. 오오~!! 다른 번역 단위들을 뒤질 필요가 없기 때문이다.
C++로 치면 unnamed 익명 클래스라든가 함수 안의 local 클래스에서 멤버 함수의 몸체가 곧장 정의되지 않은 것과 비슷한 상황이다. 이런 일회용 클래스들은 함수의 몸체를 바깥 딴 데서 찾을 만한 여지가 없다. ^^

C와 C++에서 이런 캡슐화 패러다임의 차이가 드러날 때가 있다.
한 클래스 A의 내부에서만 쓰이고 마는 내부 클래스 B를 그냥 A.cpp 안에다가 global scope로 선언할지, 아니면 A가 선언된 A.h 헤더 파일에다가 A 내부의 scope로 private 선언할지 말이다.
객체지향 이념에 따르자면 헤더 파일에다가 선언하는 게 좋지만, 실용적으로는 그냥 cpp가 낫다. 헤더에다가 넣으면 외부에 노출되지 않는 클래스인데도 수정할 때마다 그 헤더 의존하는 소스 파일들이 다 빌드되니까 말이다.

5. 나머지 C++

(1) "한 번도 참조되지 않은 변수"라고 경고(컴파일러 또는 정적 분석에 의해)가 뜨는 걸 무시하기 위해서 [](...){}(a,b,c,d,e); 라는 람다가 쓰인다니 참 대단하다. 아울러,
auto convert(const istream &input)  -> void;
void convert(const istream &input);

클래스의 멤버 함수도 이렇게 람다 스타일로 선언할 수 있으며, 위의 둘은 완전히 동치라고 한다. typedef 대신 using을 쓰는 문법과 비슷해 보인다. ㄲㄲㄲㄲㄲ

(2) 그나저나 using은 typedef의 완벽한 상위 호환이어서 typedef는 이제 쓸 필요가 전혀 없어지는 건지? signed 같은 잉여가 되는 건가 싶다. 템플릿 인자에서 class가 typename으로 대체되고 static 함수가 익명 namespace 함수로 바뀌는 것과 비슷한 양상인데, typedef는 쟤 말고는 다른 용도가 전혀 없으니 말이다.
using A = B는 파스칼에서 type A = B와 형태가 아주 비슷해 보이기도 한다.

(3) C++의 iterator들은 어지간한 건 내부 구현이 그냥 포인터 하나와 다를 바 없을 텐데.. intptr_t 같은 정수 하나로 간단하게 reinterpret_cast가 가능했으면 좋겠다. 그래야 type-safe하지 않은 C 스타일 콜백 같은 데서도 내부적으로 C++ 컨테이너의 원소에 접근할 수 있기 때문이다.
특히 list, vector 말이다. hash는 모르겠다만.. 트리 기반 컨테이너인 set, map은 그 특성상 노드들이 parent 노드 포인터까지 갖고 있는데, iterator도 포인터 하나만 갖고 있어도 다음 진행 방향을 결정할 수 있지 않은가?
하지만 포인터 하나보다 크기가 더 큰 iterator도 심심찮게 보이는 것 같다.

(4) constexpr은 C++도 단순 read-only와 진정한 constant의 구분을 두려는 시도인 듯하다. 게다가 멀쩡한 함수를 '인라인화'도 모자라서 컴파일 시점에서의 상수로 바꾼다니..
팩토리얼이나 피보나치 수열 상수를 재귀적으로 구하는 건 예전에는 템플릿 클래스의 상수값 형태로나 가능했다. 하지만 이제는 C/C++ 상으로 멀쩡하게 생긴 함수의 호출 형태로도 표현 가능해졌다.
뭐, 템플릿에서도 static_assert와 더불어 많이 활약할 것으로 예상되는데, 자세한 건 더 공부해 봐야겠다.

(5) 객체를 초기화할 때 생성자 obj(arg)나 대입 연산 obj=arg 말고 중괄호는 배열이나 구조체를 초기화할 때에나 쓰이는 물건으로 여겨졌다. 하지만 C++11부터는 이게 initializer list라는 개념으로 리모델링되어 임의의 클래스의 public 멤버들을 순서대로 초기화할 때도 쓰고, 컨테이너에다 여러 원소들을 한꺼번에 집어넣을 때도 쓰일 수 있게 됐다.
참 혁신적이긴 하지만 용도가 너무 다양한 것 같다. 모호성이 발생하지는 않는지, {...}는 그럼 R-value 리터럴인 건지, 내가 만드는 클래스에서 저런 걸 받아들이려면 어떡해야 하는지 궁금한 게 많다. 이것도 공부 필요.. =_=;

(6) 인터페이스를 여러 개 받아서 구현한 클래스가 정작 그 인터페이스들의 base로는(예: IUnknown) 모호하다고 형변환 되지 않는 오류 말이다(Visual C++ 기준 C2594). 정말 아무 의미 없고 멍청한 페이크에 가까운 오류인데..
base가 고유한 vtbl이 없고 데이터 멤버도 없다면 그냥 자기 this에서 가장 가까운 base를 언어 차원에서 알아서 지정하게 하는 게 좋지 않을까? 애초에 자기 데이터가 없는데 가상 상속을 할 필요도 전혀 없는걸? 궁금하다.
이게 언어 차원에서 interface라는 게 없고 그 대신 무식한 다중/가상 상속을 지향하며 만들어진 C++의 맹점인 것 같다.

(7) 나는 C/C++ 문법을 어지간한 건 다 마스터 해서 머리에 숙지하고 있고, 아무 코드나 보면 머릿속으로 가상의 컴파일러를 돌려서 "얘는 이런 식으로 기계어로 번역되겠다, 구현 비용이 얼마나 되겠다, 이렇게 동작하겠다, 이런 문제가 있다" 같은 게 예측이 된다고 생각해 왔다. 넓은 의미에서 암산과 비슷한 경지일 것이다. 아 당연히 난해한 코드 출품작 급의 괴물 코드 말고, 평범한 코드 말이다. -_-;;
하지만 계속해서 새로운 기능, 기괴한 기능들이 추가되고 있는 modern C++을 보면 이런 자신감이 갈수록 줄어드는 것 같다. 배배 꼬인 템플릿에다 auto에 람다에, ...에 헥헥~ 이 기능은 어떤 문법적 근거를 통해 빌드 되는 건지부터가 파악이 안 되는 것도 있다. =_=;;

요즘 C++은 정말 옛날에 내가 알던 그 C++에서 갈수록 멀어져 간다. 그 경직된 정적 타입 네이티브 코드 컴파일 언어에서 어떻게 동적 타입 언어의 유연함을 집어넣은 걸까? 특히 가변 인자 템플릿 말이다.;; (튜플!!) ㄷㄷㄷ

Posted by 사무엘

2023/11/14 08:35 2023/11/14 08:35
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/2230

4. 숫자 자리수 잉여 구분자

21세기 들어서 프로그래밍 언어들에 알음알음 몰래 도입돼 들어간 요소 중 하나로는.. 숫자 자리수를 구분하는 잉여 구분자가 있다.

가령, Java는 _ 밑줄을 이 용도로 지원한다.
그래서 a = 1234567890이라고 쓸 것을 a = 12_3456_7890이라고 써도 되고, a = 1_234_567_890이라고 써도 된다. 소수점도 3.141_592_653 이렇게 쓸 수 있고, 0xFFFF_0000처럼 타 진법도 마찬가지이다.

C++에서는 참 흥미롭게도 '(어퍼스트로피)를 동일 용도로 지원한다. C++11에서인가 추가됐다고 한다. a=1'234'567;
_은 공백을 염두에 둔 기호인 반면, '는 콤마를 염두에 둔 기호라는 차이가 있다.

이런 구분자는 컴파일러의 입장에서는 있건 없건 토큰을 인식하는 데 아무 차이가 없다. 주석과 동급으로 전혀 필요하지 않은 잉여일 뿐이다.
단지, 사람 입장에서의 가독성을 위해서 이 구분자만은 예외로 컴파일러가 이물질이라고 토해내지 않고 무시 처리해 주는 것이다.

64비트 double 부동소수점이야 16비트 시절부터 존재했겠지만, 64비트 정수 리터럴은 쌍팔년도에는 거의 볼 수 없는 물건이었지 싶다. 이 정도로 큰 수는 10진법으로 나타내도 글자 수가 거의 20자에 달하게 된다. 그러니 필요할 때 인위로 자리수를 끊어서 표기할 수 있다면 코드의 가독성 차원에서 깨알같은 도움이 될 것이다.
다만, 학술적인 의미가 있지 않고 사람이 값을 참조할 일이 없는 단순 난수표 같은 숫자 테이블이라면 굳이 자리수 구분해서 적을 필요가 없을 듯하다.

그러고 보니 C++에는 0b로 시작하는 2진법 리터럴 표기도 추가됐다. 얘는 아무래도 길이가 굉장히 길기 때문에 8자 단위로 '로 끊어서 표기하는 게 확실히 유용하겠다.
그에 비해 C 시절부터 존재했던 8진법 표기는 진짜 아무데서도 안 쓰이는 잉여가 된 것 같다. FTP 파일 권한 777 이런 것 말고 딴 데서는 도통 본 적이 없다.

참고로 C/C++에는 줄 바꿈 문자를 없애고 토큰을 한데 이어 주는 \ 역슬래시라는 강력한 기호가 있다.
C/C++은 태생적으로 줄 바꿈에 연연하지 않고 중괄호와 세미콜론으로 문장을 구분하기 때문에 \ 가 필요할 일이 그리 많지는 않다. 사실상 #define 매크로 함수를 여러 줄에 걸쳐 길게 선언하는 용도로만 쓰인다.
하지만 그 특성상,

int a=123\
456;
const char b[]="abc\
def";

이렇게 써 줘도 얘는 a=123456이라고 인식되며, b에는 "abcdef"가 들어간다. \는 컴파일러라기보다는 거의 전처리기 수준으로 소스 코드의 두 줄을 기계적으로 연결해 준다고 생각하면 된다.

이걸로 심지어 // 주석조차 다음 줄까지 계속되게 만들 수 있으니 말 다 했다.;;
참고로, 주석은 컴파일러의 입장에서 whitespace 하나로 간주된다. 그렇기 때문에 100/*ㅋㅋㅋㅋ*/00은 100과 00을 분리시키며, 100'00과 같은 역할을 할 수 없다.

객체 지향, 제네릭/메타프로그래밍, 함수형 등 갖가지 패러다임들이 C++, C#, Java 등 메이저 언어들에 다 도입되면서 프로그래밍 언어들은 서로 비슷해지는 '수렴 진화' 중인 것 같다. 물론 자기 고유한 정체성을 상실할 정도로 완전히 똑같아지지는 않겠지만 말이다.

5. 오타

현직 프로그래머 내지 소프트웨어 엔지니어는 코딩을 한다고 해서 맨날천날 시간 복잡도, 공간 복잡도 따지고 다이나믹이니 그리디니 하는 신선놀음 같은 알고리즘 고민을 하는 게 아니다.

현실에서는 알고리즘이야 이미 만들어져 있고 잘 돌아가는 검증된 라이브러리나 오픈소스를 가져와서 쓰는 게 훨씬 더 많다.
자기가 새로운 코드를 만들어 내는 것보다 남이 만든 기존 코드를 읽고 유지보수 하고 버그를 잡는 비중이 훨씬 더 크다.
그리고 그 와중에 그나마 새로운 코드를 작성하는 게 있다면.. "뭔가 이름을 붙이는 것"의 비중이 매우 크다. 동사구이든 명사구이든..

그러니 프로그래머가 자기 조직이 마음에 안 들 때 아주 교묘하게 사보타주를 하고, 자기 후임을 엿먹이고 생산성을 저해하는 효과적인 방법이 있다.
자기가 작성하는 각종 클래스, 함수 등의 이름에다가 고의로 오타를 교묘하게 집어넣는 것이다.
아주 간단하게 getUserAdress 라든가.. receiveIncommingMessage 따위.

...;; 프로그램이야 멀쩡하게 돌아가니까 그 당시에는 아무 문제가 없는데..
문제는 나중에 그 프로그램의 버그를 잡고 기능을 추가하는 등 유지보수를 할 때다.
대놓고 약어를 쓴 것도 아니고 원래 그대로 풀어 쓴 듯한 영단어가 미묘하게 스펠링이 여기저기 틀려 있으면..
나중에 "검색"이 안 되어서 미치고 펄쩍 뛰는 일이 야기된다.

이런 코드는 여러 사람을 거쳐 가며 작업을 하기 어려우며, 처음 짰던 사람이 아니면 구조를 쉽게 파악할 수 없게 된다.
도서관에서 책을 꺼냈다가 일련번호 순서가 아닌 아무데나 꽂아 넣는 것과 같은 일이 벌어진다. (잘못 꽂힌 책은 없는 책과 같습니다)

진짜.. 개발 환경에서는 프로그래밍 언어 차원에서 코드의 문법 오류만 빨간줄을 치는 게 아니라, 명칭의 영어 스펠링 오류를 체크하는 것도 꽤 도움이 되지 싶다.

먼 옛날에 컴퓨터가 너무 비싼 물건이고 텍스트 에디터의 인터페이스가 불친절· 불편하고 디스크 공간이 부족하던 시절에는
뭐든지 getpid() 이런 식으로 짧게 줄여 쓰는 게 관행이었다. PC통신 채팅이나 전보에서 '안냐쎄여' 등으로 필사적으로 줄이는 것의 코딩 버전이나 마찬가지이다.

그러나 디스크 용량 걱정이 없어지고, 한번만 명칭을 정한 뒤부터는 에디터에서 긴 명칭을 자동 완성해 주는 기능이 매우 편리하게 발달하고(거의 90년대 말.. =_=), 또 소프트웨어의 규모가 왕창 방대해지고 공동 작업의 중요성이 커진 뒤부터는 GetProcessID() 이렇게 길게 풀어 쓰는 게 더 바람직한 관행으로 정착했다.
소스 코드가 자연어와 더 비슷해지고 길어지고 나니 스펠링 오류에 대한 취약성도 더 커진 셈이다.

6. 함수 안에 함수, 클래스 안에 클래스

파스칼 내지 Ada 같은 옛날 구시대 언어 중에서는 함수 안에 함수를 만드는 걸 지원하는 경우가 있었다. 그에 비해 C는 함수 호출 구조를 단순화시키느라 그런 걸 제공하지 않았다. 한 함수 안에서만 잠깐 쓰이는 코드 반복 패턴을 표현하려면 그냥 매크로 함수를 쓰라는 취지였던 듯하지만.. 이건 막 깔끔한 해결책은 못 됐다.

오늘날은 함수형 프로그래밍이 도입되면서 람다 덕분에 함수 안에 함수를 넣는 게 '사실상' 가능해졌다. 다만, 예전에 생각했던 그런 문법이 아니라, 함수 몸체를 지역변수에다 대입하는 굉장히 이색적인 형태로 가능해졌다는 게 신기한 점이다.

함수와 달리, 클래스는 원래부터 자기 내부에 클래스를 또 가질 수 있다. 그래서 C++은 C와 달리 계층적인 다단계 scope을 구현할 수 있으며, 필요에 따라 이거 표현을 간소화하기 위해 using이라는 키워드도 도입됐다.

함수건 클래스건.. (1) 내부에 안겨 있는 녀석의 명칭은.. 걔를 품고 있는 outer의 문맥에서만 유효하고 거기서만 접근 가능하다. 이건 너무나 당연한 이치이다.
그런데 안겨 있는 녀석은.. (2) 반대로 자기를 안고 있는 outer의 멤버(클래스의 경우)나 변수(함수의 경우)에 접근 가능해야 한다.

C/C++은 (2)를 지원하는 것이 미흡하고 인색했다. 그래서 C 시절부터 함수 안에 함수 같은 건 골치 아프니 지원하지 않았으며, 클래스 안의 클래스도 바깥 클래스의 인스턴스 멤버로 접근을 지원하지 않았다. Java로 치면 static class밖에 지원하지 않은 것과 같다.
C/C++은 포인터를 그렇게도 좋아하는 언어인데, 저것들은 정수 하나짜리 날포인터만으로 구현할 수 없는 개념이어서 지원을 안 한 것이지 싶다.

static 함수야 클래스에만 소속됐지, 클래스의 각 인스턴스에 매여 있지는 않아서 this 포인터가 존재하지 않는 함수이다(0). 사실상 전역 함수이나 마찬가지이다.
그리고 일반적으로는 자기 자신을 가리키는 this는 메모리 주소 딱 1개만 가리키는 포인터이지만.. 하지만 객체지향을 제대로 구현하려면 this의 크기가 한 칸만으로 충분하다는 고정관념을 깨야 할 것 같다. inner class라든가 다중 상속은 이런 문제를 훨씬 더 복잡하게 만들기 때문이다.

하긴, 그래서 Java에서는 C++에 없는 Outer o = (new Inner()).new Outer(); 이런 코드가 가능하다. C++에서는 new 연산자를 오버로딩 하더라도 무조건 static 형태만 되는데, Outer는 this가 자기 자신뿐만 아니라 Inner까지 사실상 두 파트로 구성되는 셈이다.
이게 가능하니 C++ 같았으면 다중 상속을 해야 했을 것도 저렇게 퉁치고, 프로그래밍을 더 작은 객체 단위로 깔끔하게 할 수 있을 것 같다.

클래스는 그렇다 치고.. 함수가 outer의 변수에 접근하는 건 요즘 C++도 '캡처'라는 기능으로 제공하기 시작했다. 원래는 '클로저'라고 부르는 개념이었지 싶은데 말이다. 이건 프로그래머/사용자의 관점에서는 아주 편리한 기능이지만, 내부적으로는 역시 함수 실행 문맥을 가리키는 포인터를 집어넣고 어쩌구 하면서 꽤 힘든 과정을 거쳐서 구현된다.

Posted by 사무엘

2023/06/30 08:35 2023/06/30 08:35
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/2177

1. C++이 C보다 편리한 결정적인 요인

C++은 템플릿, 람다 등 온갖 다양한 프로그래밍 패러다임이 추가되어 C보다 훨씬 더 방대하고 복잡한 언어가 되어 있다.
그러나 C++은 맨 처음에 객체지향 언어로 시작했기 때문에 C와의 근본적인 차이는 아무래도 이와 관련된 것들이다.
본인은 C++이 C에 비해서 더 편리하고 간결하게 코딩할 수 있고 사람의 실수를 줄여 주는 제일 강력하고 중요한 요소는 다음 세 가지라고 개인적으로 생각한다.

(1) 클래스 멤버 함수 안에서 this 포인터를 생략하고 바로 자기 멤버를 참조해도 된다.
즉, C의 함수와 비교했을 때, 복잡한 구조체의 포인터인 첫째 인자는 생략 가능하다는 뜻이다. 매번 obj->member 할 필요 없이 바로 member를 쓰면 된다.

(2) 어떤 객체 변수를 선언해 주면(지역/전역) 생성자와 소멸자를 호출하는 코드가 앞뒤에 자동으로 삽입된다.
함수나 블록의 실행이 중간에 끝나더라도(return, break) 메모리를 해제하거나 파일을 닫는 코드를 거치게 하려고 지저분한 goto문을 쓰지 않아도 된다. 예외를 던질 때에도 소멸자 처리가 자동으로 된다는 건 longjmp 따위로 결코 흉내 낼 수 없는 엄청난 축복이다.

(3) 상속이라는 걸 자동으로 제공하고, 포인터 형변환 때의 상위· 하위 상속 관계를 자동으로 맞게 판단해 준다.
파생에서 기반으로 가는 건 괜찮지만, 기반에서 파생으로 가는 건 바로 안 되고 최소한 static_cast라도 해 줘야 된다.
그에 비해 C언어는 void*냐 그렇지 않느냐 하나만 판단하고, void*가 아닌 다른 모든 타입의 포인터들은 서로 남남인 타입일 뿐이다.

2. C++과 Java의 enum class

컴퓨터 프로그램에서는 숫자가 산술 연산의 대상인 수가 아니라 그냥 이산적인 식별 번호로 취급되고, 각각의 값이 서로 완전히 다른 의미를 갖는 경우가 많다. 그래서 프로그래밍 언어에서는 범용적인 정수형뿐만 아니라 sub-range 내지 열거형이란 걸 제공하곤 한다.

sub-range는 파스칼이나 Ada 같은 옛날 언어 유행으로 끝나는 분위기이고, 요즘 대세는 열거형이다.
C언어는 열거형이란 게 있긴 했지만 모종의 이유로 인해 매크로 상수가 훨씬 더 많이 쓰였다. 하긴, 그쪽은 참/거짓 bool 형조차 없었고 그냥 다 int로 퉁쳐서 썼을 정도로 int 만능 덕후 성향이 좀 있었다. =_=

C++에서는 C++11 버전부터 enum class라는 것이 도입됐다. (1) scope을 반드시 지정해 줘야 하고, (2) 정수형으로 암시적으로 형변환이 되지 않아서 type-safety가 강화되니 굉장히 적절한 변화인 것 같다.
즉, 평범한 enum이라면 int를 받는 아무 곳에서나 ENUM_VALUE라고만 써도 됐을 텐데, enum class라면 반드시 static_cast<int>( EnumClass::ENUM_VALUE ) 라고 길게 지정해 줘야 하게 된 것이다. type safety가 강화되었다.

Java에도 enum이 있긴 하지만, 후대인 Java 5에서 추가로 도입된 물건이다. 그렇기 때문에 거기도 상수 명칭을 선언하는 용도로는 재래식 static final int 뭉치가 더 많이 통용돼 왔다.
같이 도입된 건지 또 나중에 추가된 건지는 모르겠지만, Java에도 enum class라는 게 존재한다. 그런데 이건 C++과는 관점이 전혀 다른 재미있는 물건이다.

public enum Planet {
    MERCURY (3.303e+23, 2.4397e6),
    VENUS   (4.869e+24, 6.0518e6),
    EARTH   (5.976e+24, 6.37814e6),
    MARS    (6.421e+23, 3.3972e6),
    JUPITER (1.9e+27,   7.1492e7),
    SATURN  (5.688e+26, 6.0268e7),
    URANUS  (8.686e+25, 2.5559e7),
    NEPTUNE (1.024e+26, 2.4746e7);

    private final double mass;   // in kilograms
    private final double radius; // in meters
    Planet(double mass, double radius) {
        this.mass = mass;
        this.radius = radius;
    }
   (... 이후 생략)
}

이렇게, enum {} 내부에는 명칭들을 쭈욱 쓴 뒤, 세미콜론을 찍으면 명칭 나열을 종결할 수 있다.
그 뒤, 다음부터는 클래스를 선언하는 것처럼 public이니 private니 어쩌구 하면서 멤버 함수와 멤버 변수를 쓰면 얘는 enum의 탈을 쓴 평범한 클래스가 된다.

허나, 이 enum 클래스는 new 연산자를 사용해서 임의의 인스턴스를 만들 수 없다. 이 enum의 인스턴스는 저 명칭으로 선언된 녀석들만이 허용된다. 그래서 enum 명칭을 선언과 동시에 저렇게 생성자 함수에다 전할 인자를 ()로 지정할 수 있다. 우와~~~

즉, 일반적으로 enum 명칭들은 0 1 2 3  같은 숫자의 alias에 불과한 반면, enum class는 각각의 명칭들이 이 클래스의 붙박이 인스턴스가 된다는 것이다.
enum은 상수를 나타내는 만큼, enum 클래스는 멤버들도 다들 final로 선언해서 실행 중에 값이 변경되지 않는 속성을 지정하게 하는 편이다.

enum 명칭이 하나밖에 없으면..?? 얘는 자연스럽게 이 클래스의 싱글턴/단일체가 된다. 그렇기 때문에 Java의 enum class는 싱글턴을 만드는 정석 디자인으로 통용되기도 한다.
생성자를 private로 감추는 등 별별 쑈를 해도 serialize나 reflect 같은 꼼수를 통해 싱글턴 객체를 여러 개 만드는 게 가능한 반면, enum class는 언어 차원에서 그런 일이 벌어지지 않는다는 게 보증된다.

정말 신기한 용법이다. C++의 enum class는 클래스처럼 취급되는 enum이지만, Java의 enum class는 enum처럼 생긴 클래스라고 볼 수 있겠다.

3. 이름이 붙지 않은 일회용 함수/클래스

2000년대 이후부터는 C++, C#, Java 같은 주류 프로그래밍 언어에 객체지향뿐만 아니라 함수형이라는 패러다임이 도입되었다. 덕분에 중괄호 {}로 둘러싸인 코드를 통째로 변수에 대입한다거나, 심지어 함수의 인자로 일회용으로 익명으로 전하는 게 가능해졌다. 인자를 받아서 리턴값을 주는 코드의 묶음이지만 굳이 함수의 형태로 선언· 정의하고 이름을 붙일 필요가 없다는 것이다.

심지어는 클래스까지 이렇게 간편하게 선언해서 그 인스턴스를 넘겨줄 수 있다.
Java에서 무슨 이벤트에 대한 handler나 listener를 인자로 넘겨줄 때, new XXXX { } 이러면서 객체 선언과 새 파생 클래스 선언과 주요 함수 오버라이딩을 한번에 하는 것 말이다.

그런데, 이렇게 이름 없는 함수나 이름 없는 클래스는 태생적으로 이름이 필요한 요소를 언어의 문법 차원에서 구현할 수 없다.
람다 함수는 자기 자신을 호출하는 재귀호출을 구현할 수 없다.
그리고 이름 없는 클래스는.. 정말 웃기게도 컴파일러가 기본 생성해 주는 것 말고 자신의 독자적인 생성자와 소멸자를 가질 수 없다. =_=;; 흠..

C++은 Java처럼 저렇게 함수 인자에서 새 파생 클래스를 즉석에서 만드는 것까지 지원하지는 않지만.. 새 클래스를 선언할 때 이름을 생략할 수 있다. 이건 반대로 Java에서 지원하지 않는 문법이다.
이름 없는 클래스나 함수를 만드는 게 가능하니 이름에 의존하지 않고 생성자· 소멸자나 함수 자기 자신을 지칭하는 방법이 있긴 해야 할 텐데.. 이건 그냥 언어 차원에서의 한계로 남겨 두려는가 보다.

참고로 C++은 이름 없는 namespace라는 것도 지원해서 얘는 C의 static의 상위 호환으로 간주하고 있다. 즉, 이 영역에 선언되는 함수나 변수는 다른 번역 단위에서는 인식되지 않는 private한 물건이 된다.
그 밖에 이름 없는 구조체· 공용체도 있는데, 개인적인 생각은 오프셋 보정을 위해 크기(자리)만 차지하는 용도이고 실제로 쓰이지는 않는 멤버에 대해서도 이름을 생략할 수 있었으면 좋겠다.

Posted by 사무엘

2023/06/27 08:35 2023/06/27 08:35
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/2176

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

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

블로그 이미지

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

- 사무엘

Archives

Authors

  1. 사무엘

Calendar

«   2024/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:
2620035
Today:
3034
Yesterday:
1544