상위 문서: C(프로그래밍 언어)
프로그래밍 사이트 선정 프로그래밍 언어 순위 목록 | ||||
{{{#!wiki style="margin: 0 -10px -5px; word-break: keep-all" {{{#!wiki style="display: inline-table; min-width: 25%; min-height: 2em;" {{{#!folding [ IEEE Spectrum 2024 ] {{{#!wiki style="margin: -5px 0" |
<rowcolor=#fff> 스펙트럼 부문 상위 10개 프로그래밍 언어 | 직업 부문 상위 10개 프로그래밍 언어 | ||
1 | Python | 1 | SQL | |
2 | Java | 2 | Python | |
3 | JavaScript | 3 | Java | |
4 | C++ | 4 | TypeScript | |
5 | TypeScript | 5 | SAS | |
6 | SQL | 6 | JavaScript | |
7 | C# | 7 | C# | |
8 | Go | 8 | HTML | |
9 | C | 9 | Shell | |
10 | HTML | 10 | C++ |
}}}
}}}
- [ Stack Overflow 2024 ]
- ||<tablewidth=100%><width=9999><-4><bgcolor=#FFA500><tablebgcolor=#fff,#222> 2024년 Stackoverflow 설문조사 기준 인기 상위 25개 프로그래밍 언어 ||
1 JavaScript 14 Rust 2 HTML, CSS 15 Kotlin 3 Python 16 Lua 4 SQL 17 Dart 5 TypeScript 18 어셈블리어 6 Bash 19 Ruby 7 Java 20 Swift 8 C# 21 R 9 C++ 22 Visual Basic 10 C 23 MATLAB 11 PHP 24 VBA 12 PowerShell 25 Groovy 13 Go
- [ TIOBE 2024 ]
- ||<tablewidth=100%><width=9999><-4><bgcolor=deepskyblue><tablebgcolor=#fff,#222> 2024년 8월 기준 검색어 점유율 상위 20개 프로그래밍 언어 ||
1 Python 11 MATLAB 2 C++ 12 Delphi / Object Pascal 3 C 13 PHP 4 Java 14 Rust 5 C# 15 Ruby 6 JavaScript 16 Swift 7 SQL 17 Assembly language 8 Visual Basic 18 Kotlin 9 Go 19 R 10 Fortran 20 Scratch {{{#!wiki style="margin: 0 -10px -5px; min-height: calc(1.5em + 5px);"
{{{#!folding [ 21위 ~ 50위 펼치기 · 접기 ]
{{{#!wiki style="margin: -5px -1px -11px"21 COBOL 36 Scala 22 Classic Visual Basic 37 Transact-SQL 23 LISP 38 PL/SQL 24 Prolog 39 ABAP 25 Perl 40 Solidity 26 (Visual) FoxPro 41 GAMS 27 SAS 42 PowerShell 28 Haskell 43 TypeScript 29 Dart 44 Logo 30 Ada 45 Wolfram 31 D 46 Awk 32 Julia 47 RPG 33 Objective-C 48 ML 34 VBScript 49 Bash 35 Lua 50 Elixir
- [ PYPL 2024 ]
- ||<tablewidth=100%><width=9999><-4><bgcolor=green><tablebgcolor=#fff,#222> 2024년 8월 기준 검색어 점유율 상위 20개 프로그래밍 언어 ||
1 Python 11 Objective-C 2 Java 12 Go 3 JavaScript 13 Kotlin 4 C# 14 MATLAB 5 C/ C++ 15 PowerShell 6 R 16 VBA 7 PHP 17 Dart 8 TypeScript 18 Ruby 9 Swift 19 Ada 10 Rust 20 Lua
}}} ||
프로그래밍 언어 목록 · 분류 · 문법 |
프로그래밍 언어 문법 | ||
{{{#!wiki style="margin: -16px -11px; word-break: keep-all" | <colbgcolor=#0095c7><colcolor=#fff,#000> 언어 문법 | C( 포인터 · 구조체 · size_t) · C++( 자료형 · 클래스 · 이름공간 · 상수 표현식 · 특성) · C# · Java · Python( 함수 · 모듈) · Kotlin · MATLAB · SQL · PHP · JavaScript · Haskell( 모나드) |
마크업 문법 | HTML · CSS | |
개념과 용어 | 함수( 인라인 함수 · 고차 함수 · 람다식) · 리터럴 · 상속 · 예외 · 조건문 · 참조에 의한 호출 · eval | |
기타 | #! · == · === · deprecated · NaN · null · undefined · 배커스-나우르 표기법 | |
프로그래밍 언어 예제 · 목록 · 분류 | }}} |
1. 개요
C의 자료형 중 하나로서, 다른 변수 또는 함수의 주소를 값으로 갖는 변수이다.이를 통해 다른 변수 또는 함수를 가리킬 수 있으며 포인터를 통해 변수에 접근하여 읽고 쓰거나 함수를 실행할 수 있다. 형 변환과 결합하면 메모리의 특정 객체를 마치 다른 타입인 것처럼 접근할 수 있으며, 이를 통해 메모리를 byte 단위로 직접 읽고 쓰거나, 중첩된 구조체를 필요에 따라 서로 다른 타입인 것처럼 접근하여 OOP를 흉내 낼 수도 있다. Memory-mapped I/O를 지원하는 하드웨어의 경우에는 포인터를 통해 미리 약속된 상수 주솟값의 위치에 직접 읽고 쓰기를 수행하여 하드웨어를 직접 제어할 수도 있다[1].
C에서는 배열, 문자열 처리, 메모리 할당 및 해제와 같은 기본적인 기능들도 포인터를 통해 처리하도록 하고 있다. 따라서 포인터를 알지 못하면 그런 기본적인 기능들조차 안전하게 이용할 수 없으며, 연결 리스트, 트리를 포함한 각종 자료 구조를 구현할 때 역시 필수로 사용된다. 포인터는 C 언어의 뼈대를 이루고 있는 중요한 개념이며, 다른 프로그래밍 언어 대신 C를 쓴다는 것 자체가 포인터라는 도구를 적극적으로 활용할 의도가 있다고 생각하면 된다.
포인터는 간단하고 단순하며 강력하고 저비용이긴 하지만 그만큼 프로그래머의 주의를 요하며 실수를 일으키기도 쉽고 잘못 사용할 경우 매우 위험하다. 그래서 최근의 고급 언어들은 포인터와 같은 방식의 메모리 직접 접근을 금지하는 추세이다.
포인터 또한 기계어와의 일대일 대응이 아니며, 다른 언어의 참조보다 얇고 직접적이긴 하지만 나름대로 추상화된 개념이다. 따라서 C가 가정하는 추상 기계 계층이 코드와 하드웨어 사이에 끼어 있어 의도치 않은 동작이 있을 수 있다는 것을 항상 염두에 두고 코드를 작성하여야 한다.
2. 역사
시초는 기계어(와 어셈블리어)에서 이용되는 간접 참조인데, 이는 명령어에서 이용할 값을 명령어 코드에 직접 쓰는 것이 아니라 특정 메모리 번지에 있는 값을 읽어서 이용하라고 하는 것이다. (예를 들어 '자연수 3'을 더하라고 할 수도 있지만 '메모리 0x1000에 저장된 값'을 더하라 명령할 수도 있다.) 이때 메모리의 값을 먼저 읽는 명령을 쓰고 더하는 명령을 추가로 쓰면, 아무래도 효율이 떨어지기 때문에 같이 처리하는 명령이 생겼다.이후 이러한 아이디어가 C와 C++ 등 다른 프로그래밍 언어에도 적용되면서 현재의 포인터가 됐다. [2]
많은 양의 데이터(특히 객체)를 다루는 현대의 프로그래밍 언어에서 포인터가 아주 없기란 사실상 불가능하지만, Java나 C#과 같이 최근의 고생산성 언어뿐만 아니라 C++11 이후의 모던 C++ 또한 포인터(raw pointer)를 가능한 한 직접적으로 건드릴 필요가 없는 방향으로 가고 있다.
3. 정의
함수 타입이나 객체 타입을 참조되는 타입(referenced type)이라고 한다. 포인터 타입은 참조되는 타입으로부터 유도될 수 있다. 포인터 타입은 객체를 가리키는 데 사용되며, 그 값은 참조되는 타입의 엔티티를 가리키는 참조를 제공한다. 어떤 참조되는 타입 T로부터 유도된 포인터 타입을 'pointer to T'라고 한다. 참조되는 타입 T로부터 유도된 포인터 타입을 생성하는 것을 포인터 타입 유도(pointer type derivation)라고 한다. 포인터 타입은 완전한(complete) 객체 타입이다. - N1570 C11 draft
C의 자료형 중에서는 배열, 구조체, 공용체, 함수와 함께 유도형(derived type)으로 분류된다. 어떤 자료형이든 선언 시에 해당 변수명의 앞에 *(별표)를 붙이면 앞의 자료형을 가리키는 포인터 변수가 된다. 이렇게 선언된 포인터 변수에 주소를 얻고 싶은 변수의 앞에 &를 붙여주면 그 변수의 주솟값이 저장된다.선언 이후 실행 중에는 char, int 등의 자료형을 안 쓰듯이 *를 안 붙이고, *를 붙이면 간접 참조 하여 해당 주소에 저장된 자룟값에 접근하여 복사나 대입을 통한 변경을 할 수 있다. 선언 시의 *는 포인터형이라는 표시자이고 실행 시의 *는 간접 참조 연산자이다.
포인터 변수가 가리키는 대상은 또 다른 포인터 변수를 중첩하여 가리킬 수도 있다. 중첩하는 단계마다 *를 추가하면 몇 번이고 중첩할 수 있다.
다른 언어의 유사한 개념과 비교하자면, C의 포인터는 상당히 단순한 구조와 기능만을 가지고 있으며 언어 자체적으로도 여러 맥락에서 단지 값으로만 취급된다. 이에 비해 C# 과 같은 매니지드 언어는 별도의 '참조(reference)형'이라는 타입이 있다. C#이나 Java에는 참조형만을 사용해야 하는 경우를 처리하기 위해서 기본형에 대한 wrapper class들이 존재하고 이로 인해 boxing과 unboxing 동작이 필요해진다. C와 C++은 기본형에 대한 포인터나 참조를 사용하면 되므로 boxing이나 unboxing이 존재하지 않는다.
4. 설명
먼저 이걸 이해하려면 RAM의 내부 메커니즘을 이해해야 한다. 순서대로 로터와 바늘이 뺑뺑 돌면서 쓰고 읽는 하드 디스크[3]에 비교하면 RAM은 빠른 액세스를 위해 데이터가 존재하는 주소 목록 파트와 실제 데이터가 있는 파트로 구분된다. (전공책 같은 데 있는 목차를 생각하면 이해가 쉬울 것이다.) 그래서 실제 데이터 부분과 주소 목록 파트가 따로 존재하는데 이 주소 목록 파트를 이용하면 언제 어떤 함수에서도 일정한 위치에 존재하는 값을 찾아서 접근할 수 있는 것이다.선언문에서 가리키고자 하는 변수의 자료형을 맞추어 변수의 식별자 이름 앞에 *(별표)를 붙이면 포인터 변수가 된다(단, 연산자 우선순위에 주의 필요).
이렇게 선언된 포인터 변수가 실행문에서는, 일반 자료형 변수를 선언문에서 int a;라고 선언하여 변수 a가 정수형이라고 선언하고, 실행문에서는 int를 안 쓰고 변수명 a만으로 사용하듯이, *를 빼고 변수명만 사용하여 처음 접하는 초보자들이 *가 없어 혼동할 수 있으니 선언문에서와 실행문에서의 사용을 구분해야 한다.
중요한 조건은 자료형을 동일형으로 유지해야 한다. 포인터의 사용 목적은 어느 변수를 그 변수명으로 가리키지 않고 그 주솟값으로 가리켜서 그 변수의 값(자룟값)을 간접 참조(역참조) 하는 것이므로 정수형 4 바이트 변수를 가리켰는데 같은 변수명이라도 문자형이나 실수형 변수에 연결되어 간접 참조 하여 값을 읽어 오면 기억 공간(memory)의 크기가 다르므로 문자형의 1 바이트 이후의 3 바이트를 더 읽거나, 실수형 8 바이트 내의 4 바이트만을 읽어 오므로 크기(바이트 수)가 다를 뿐만 아니라 기록되어 있는 내용(값)이 다르므로 자료형이 가리켜지는 변수와 포인터 변수가 같아야 한다.
수식 내에서 일반 변수의 주소(주솟값)를 알아보려면 변수명에 &를 붙이면 된다(&a). 이것은 포인터가 가리키는 주소이므로 int a;로 정수형 변수를 선언했으면 같은 자료형 정수형으로 int *pa;로 a를 가리키는 포인터 pa를 선언하면 수식에서는 pa가 &a와 같게 된다.
수식에서 *는 일반 연산자로는 곱셈을 하고, 참조 연산자로는 해당 주소의 값(자룟값)을 간접 참조(역참조) 하므로 *&a는 *pa와 동일하다. 결국 a, *&a, *pa는 동일한 수식이 된다.
포인터 변수를 이용하여 그 변수가 가리키는 위치에 값을 대입할 때에는, *pa = xx로 포인터에 참조 연산자를 붙여 값을 참조한다는 표시를 하고 일반 변수의 값을 할당할 때와 같이 대입 연산자 =를 이용하여 간접 참조로 가리킨 변수의 값을 xx로 변경한다.
위의 설명을 간략히 하면 아래와 같다.
- 포인터: 주솟값(간접 참조 하기 위한 변수의 주소)을 저장하는 변수
- 포인터 선언: 선언문에서 주소를 저장하는 변수와 같은 자료형을 명시하고 별표를 변수명 앞에 붙여 표시
- 포인터 사용: 실행문 내에서는 별표 없이 변수명만 사용(일반 변수들을 선언문에서 자료형 지정 하고 실행문에서 변수명만 사용과 동일)
- 주소: 공간의 첫 주소(첫 주솟값)를 말하며 문자형 자료는 1 바이트이니 그 자체, 정수형 자료를 갖는 변수의 주소는 4 바이트의 첫 주소가 전체 4 바이트의 공간을 의미, 이중 실수형 자료를 갖는 변수의 주소는 8 바이트의 첫 주소가 전체 8 바이트의 공간을 의미, 표시
- 직접 참조: 변수에 접근하여 자룟값을 읽거나(복사하거나) 변경
- 간접 참조: 변수의 주소를 통해 자룟값을 복사하거나 변경
#!syntax cpp
int exp_a;
int * exp_a_p; // int* exp_a_p;, int *exp_a_p;와 동일
exp_a = 12;
exp_a_p == &exp_a;
*exp_a_p == *&exp_a == exp_a == 12;
변수 주소 - 주소 연산 한(주소 연산자 &를 붙인) 변수명, &exp_a
1. 자룟값(내용물) - 변수에 대입된 자료
2. 연산 결과 - (자료가 담긴) 해당 변수의 (기억 공간의) 주소
3. 연산 목적 - 주소를 통해 해당 자료형의 공간 표시
포인터 exp_a_p - 간접 참조 하기 위한 변수의 주소를 저장한 변수
1. 자룟값(내용물) - 가리키는(지시, 지적하는, pointing) 해당 자료형 자료의 시작 주소, 첫 주소
2. 목적 - 가리킨(지시한, 지적한, pointing) 주소를 통해 기억 공간(대상, 목적물)을 간접 참조 하여 자료를 복사하거나 변경
3. 조건 - 간접 참조가 목적이므로 지적한 변수와 자료형이 일치 필요
역할 구분
int * pa = &a; 선언 시 선언문에 변수 a의 주소를 포인터 변수 pa에 저장
포인터 변수 pa가 a의 주소 &a를 저장하고 있으므로 pa == &a
*pa는 a의 주소 &a를 통해 간접 참조 한 a의 자룟값이므로 *pa == *&a == a
&pa는 포인터 변수 pa 자체의 주소, 다중 포인터에서 사용
scanf에서 입력받아 변수 a에 직접 저장할 수 없어 그 주소 &a를 통해 넣음
배열명 또는 수식의 결과물 중 배열 타입을 갖는 것 - 배열의 첫 주소인 상수(첫 주솟값이므로 포인터 변수)
포인터 변수가 가리키는 대상은 또 다른 포인터 변수를 중첩하여 가리킬 수도 있다. 중첩하는 단계마다 *를 추가하면 몇 번이고 중첩할 수 있다.
5. 다른 자료형과의 관계
5.1. 정수와 포인터의 관계
포인터 변수가 정수형으로 변환된 주솟값은 꼭 실제 물리적 주솟값일 필요는 없다. 실제 물리적 주소이거나, 물리적 주소의 일부분만이거나, 실제 물리적 주소에 추가 정보가 포함되어 있거나, 실제 주소가 어떤 매핑된 결과물이거나 하는 등의 다양한 유형들이 있다.포인터는 정수형과 다른 방식으로 계산된다. 포인터에 대한 덧셈과 뺄셈은 배열의 인덱스 연산이나 iterator 패턴의 prev(), next()에 가깝다. 예를 들어 포인터에 1을 더하면 주솟값에 그대로 1이 더해지는 것이 아니라, 가리키는 데이터형의 크기만큼 주솟값이 늘어나서 결과적으로는 그다음의 원소를 가리키게 된다. 따라서 가리키는 자료형의 크기를 알 수 없는 void *형의 경우에는 덧셈과 뺄셈이 불가능하다.
모든 포인터형은 정수형과 상호 변환이 가능하나 제약이 있으므로 항상 상호 변환이 가능하지도 않다. 즉, 아무 정숫값이나 포인터로 변환하여 쓸 수는 없다. 변환 규칙과 결과 자체는 컴파일러에 따라 다르고, 메모리 정렬 제한이나 CPU에 따라서는 읽기만 해도 버스 에러가 나는 함정 표현 등이 있어서 주의가 필요하다.
포인터 변수 안에 담긴 실제 비트 표현과, 그 포인터 변수가 정수형으로 형 변환 됐을 때의 정숫값이 동일할 필요도 없다. 드물게, 널 포인터 변수의 내부 표현이 0이 아닌 환경이 존재한다(정수형 상수 0과 비교하면 참으로 판정되지만, 변수 내부의 비트 패턴을 열어보면 0이 아니다).
5.2. 배열과 포인터의 관계
흔히 '배열은 포인터 상수이다'라고 표현하기도 하는데, 이는 정확한 설명은 아니다. 배열과 포인터 사이에 성립하는 정확한 규칙은 다음과 같다.배열 타입의 수식(배열 이름도 여기에 해당한다)은 그 배열의 첫 번째 원소를 가리키는 포인터로 자동 변환 된다. 이 결괏값은 lvalue가 아니다. 단, sizeof 연산자의 피연산자로 쓰인 경우, & 연산자의 피연산자로 쓰인 경우, char형 배열의 초기화에 쓰이는 문자열 상수인 경우는 그 예외이다.
배열 참조 연산자
arr[1]
은 *(arr + 1)
과 동일하며, 이 수식은 위의 규칙을 적용받아 해석된다. arr은 배열의 그 첫 번째 원소의 주솟값이 되고, 여기에 1을 더하면 포인터 덧셈 연산의 정의에 따라 그다음 원소인 배열의 2번째 원소를 가리키는 주솟값이 된다. 그리고 여기에 참조 연산자 *를 적용하면 *(arr + 1)
은 배열의 두 번째 원소 그 자체가 된다. *(arr + 1) = *(1 + arr) = 1[arr]이므로 1[arr]
이라는 기괴한 수식도 유효하며, C 설계자들도 저런 수식을 허용하지 않으려 했으나 배열 참조 연산자를 포인터를 사용해서 정의했기 때문에 어쩔 수 없이 그대로 두었다고 한다.위의 규칙은 수식 안에서만 적용되는 규칙이며, 변수를 선언할 때는 적용되지 않는다. 따라서 배열로 선언하면 배열이 만들어지고, 포인터로 선언하면 포인터 변수가 만들어진다. 단, 함수의 매개 변수를 선언할 때는 다르다. 아래 설명 참조.
5.3. 함수 매개 변수에서의 배열과 포인터의 관계
위 규칙은 함수 선언의 매개 변수 선언 부분에서도 유사하게 적용된다. 함수 매개 변수에서 배열을 선언해도 실제로는 포인터 변수가 만들어진다. 따라서 함수의 매개 변수로 배열을 사용할 수 없고, 함수 호출 시에 함수 인자로 배열을 그대로 복사하여 전달할 수도 없다.5.4. 함수와 포인터의 관계
함수 타입에 대해서도 이와 비슷한 규칙이 존재하는데, 함수 타입의 수식은 & 연산자와 sizeof 연산자의 피연산자[4]로 쓰일 때를 제외하고는 함수 포인터 타입의 수식으로 자동 변환 되고, 정작 함수의 호출은 함수 포인터 타입의 수식으로 이루어진다.예를 들어 함수 f()와 그 함수 포인터 fp에 대해
fp = &f; fp = f; fp = *f; fp = ****f;
넷 다 맞는 수식이고 동일한 동작을 한다. 그러나 fp = &&f
는 틀린 수식이다. 또한 f(); (*f)(); (****f)(); fp(); (*fp)(); (***************fp)();
모두 다 올바른 함수 호출식이다.모든 함수의 포인터 타입은 서로 호환된다. 따라서 (void *) 같은 별도의 범용 포인터가 필요하지 않고, 어떤 타입의 함수 포인터 타입을 함수의 범용 포인터 타입으로 사용해도 상관없다. 단, 함수 포인터로 함수를 호출할 때는 원래의 올바른 타입으로 변환하여 호출해야 한다.
함수의 포인터 타입은 void *와 호환되지 않는다. 따라서 함수의 포인터를 void *로 받거나 void *를 함수의 범용 포인터 타입으로 사용하는 것은 표준의 관점에서는 잘못된 용법이다.
6. 포인터의 크기와 형 변환
포인터의 크기는 하드웨어마다 다를 수 있으며, 더군다나 C에서는 같은 컴퓨터 내에서도 모든 포인터의 크기가 같을 필요가 없다. 포인터형 사이의 형 변환에서 가장 자유롭고 또한 가장 크기가 큰 포인터 자료형은 일반 포인터로 활용되는 void *이며, 그 외에 각종 바이트 단위로 데이터를 읽거나 쓸 때 활용되는 char * 정도다. 그 외에는 명백히 서로 호환되거나 하는 경우에만 상호 변환이 가능하기 때문에 포인터 사이의 형 변환에는 주의가 필요하다.포인터를 깊이 이해하기 위한 핵심 개념 중 하나는 메모리의 정렬이다. 어떤 종류의 CPU에서는 각각의 데이터형들에 대해 그 메모리상의 주솟값이 CPU의 구조와 메모리의 최소 단위에 따라 정렬되기를 요구하며, 이를 염두에 두지 않고 함부로 형 변환을 했다간 CPU 수준에서 에러가 발생할 수 있다. 메모리의 정렬에 엄격하지 않은 CPU들도 메모리의 정렬에 어긋나면 한 번에 읽을 것을 두 번에 걸쳐 읽거나 하여 성능 저하가 발생할 수 있다. 포인터를 사실상 거의 모든 곳에서 사용하는 SIMD 프로그래밍은 이 제약이 더 까다로운데, 사용할 벡터 레지스터의 크기에 포인터가 정렬되지 않은 경우 CPU에서 보호 오류를 내며 프로그램이 종료되어 버린다.
7. 장단점
7.1. 장점: 간결함과 효율성
포인터의 기본 아이디어 자체는 매우 간단하고 친숙한 개념이다. 어떤 대상에 대해 필요할 때마다 다른 별명을 부여하는 것, 혹은 어떤 위치를 가리킬 때 이를 숫자로 추상화해 가리키는 것 등은 일상생활에서도 학생 번호나 아파트 동·호수 등에서 쉽게 접할 수 있다.어떤 언어든간에 다른 대상을 가리킬 수 있는 참조 기능은 매우 중요하다. 추상화를 위해 다른 객체에 새로운 이름을 붙여 가리켜야 할 때, 큰 덩어리의 자료를 메모리 안에서 주고받을 때, 연결 리스트, 트리(그래프) 등과 같은 각종 알고리즘과 자료 구조를 사용해야 할 때 등 여러 상황에서 적절한 참조 기능은 반드시 필요하다. 포인터는 그러한 참조 기능을 가장 간단하게 친숙한 방법으로 추상화한 것이다.
다른 언어의 참조 기능에 비해 C의 포인터는 개념 자체가 간결하면서도 기계 위에서 효율적으로 동작한다는 장점이 있다. 기계어가 거의 그대로 추상화된 개념이고 안전장치가 거의 없어 추가적인 연산이나 메모리 소모 또한 거의 없기 때문이다.
7.2. 단점: 난해함과 위험성
포인터에 대해 데니스 리치의 책 《C 프로그래밍 언어》는 ' GOTO와 함께 이해 불가능한 프로그램을 만드는 놀라운 방법으로, 부주의하게 사용할 경우 이는 사실이다'라고 설명하고 있다. 포인터는 이해하기 어렵기로 악명이 높고, 위험하기까지 하다. 포인터의 기본 아이디어는 매우 간단하고 친숙하지만, C의 포인터는 그것으로 끝이 아니기 때문이다.우선 포인터가 사용된 변수 선언이나 수식을 이해하는 것부터가 어렵다. 포인터 연산자, 배열 참조 연산자, 주소 연산자 등 갖가지 특수 문자들이 중첩되어 있는 선언이나 수식을 각항의 우선순위와 데이터형 변화를 추적해 가며 올바로 이해하는 것은 상당한 훈련이 필요하다. 또한 배열과 포인터 관련한 규칙들은 역사적인 이유로 매우 비직관적으로 꼬여있어, 이와 관련된 규칙과 문법을 이해하는 것은 결코 쉽지 않다.
여기에 참조를 알고리즘에 응용하기 시작하면 난이도는 더더욱 올라간다. 다른 객체에 추상적인 별명을 부여해 관리한다는 것 자체는 너무 간단한 개념인데, 이 간단한 기능으로 각종 알고리즘과 자료 구조를 구현하기 시작하면 머리가 터진다[5].
포인터를 제대로 이해했다 하더라도 그걸로 끝이 아니다. C의 포인터는 안전장치가 거의 없다. 경계선 침범(buffer overflow), 메모리 누수, 죽은 객체에 대한 참조(dangling pointer), 메모리 정렬(memory alignment), 함정 표현(trap representation) 등의 여러 메모리 관련 문제에 대해 C는 어떠한 안전장치도 제공하지 않는다.
메모리 객체의 생성과 소멸을 포인터를 통해 프로그래머에게 떠넘기는 점 또한 문제이다. 간단한 가비지 컬렉션 기능조차 없어 해제되지 않은 메모리가 끝도 없이 늘어나는 경우도 많고, 복잡한 로직 속에서 실수로 죽은 객체에 대한 참조가 일어나기도 쉽다. 아무리 경험과 실력을 쌓고 조심한다 한들 복잡한 코드와 동작 속에서 모든 객체의 생성과 소멸을 완벽하게 관리하기란 거의 불가능에 가깝다. 구글과 MS에서조차도 모든 보안 버그 중 70%가 메모리 보안 버그일 정도이다.
오래된 유산들에 의한 직관적이지 않은 규칙들도 프로그래머의 혼란을 부추긴다. 왜 배열은 배열이 아니고 포인터로 바뀌는가? 왜 함수의 매개 변수로 배열을 사용할 수 없는가? 왜 배열끼리는 대입이 안 되는가? 왜 함수는 그 자체로 함수의 주솟값인가? 왜 array와 &array가 같은 주솟값을 가리키는가? 왜 fp = &&&&func_name는 안 되는데 (****fp)();는 되는가? 왜 기본 인자 진급 같은 이상한 규칙이 존재하고 왜 가변 인자 함수는 그렇게 빙 돌아가는 방식으로 작동하는가?
거기에 추가로 포인터에는 이식성 문제까지 복잡하게 얽혀있다. CPU 구조에 따른 엔디언 문제, 하드웨어마다, 운영 체제마다, 심지어 컴파일러에 따라서도 달라지는 정수형 및 포인터 크기 모델, 온갖 변태적인 메모리 구조 및 함정 표현과 그 1%도 안 되는 상황들을 배려하기 위해 생겨나는 복잡한 예외 규칙들, 잘못된 가정에 의해 작성된 코드 때문에 발생하는 잘못된 최적화 문제 등, 골치 아픈 문제들로 가득하다.
그렇다고 포인터를 어렵다고 그냥 건너뛸 수도 없다. C에서는 배열, 문자열 처리, 표준 입출력 함수, 동적 메모리 할당 등과 같은 아주 기초적인 기능에서조차 포인터를 적극적으로 활용하며 또한 이에 대해 깊이 있는 이해를 요구한다. 따라서 C로 뭔가 의미 있는 프로그램을 작성하기 위해서는 포인터를 반드시 배우고 넘어가야 한다.
포인터의 악명은 DOS 시절로 거슬러 올라간다. DOS 시절은 16비트 CPU의 한계로[6] 포인터도 near 포인터(2바이트), far 포인터(4바이트)로 나뉘어 있었는데 이 시절에는 저 포인터들을 다 컨트롤하기 쉽지 않았다(주소 계산 방식부터 다르다). tiny, small, medium, compact, large 등등의 메모리 모델이 있었고 각각의 모델마다 사용되는 포인터가 달랐다. data segment가 한 개뿐인 모델에는 near 포인터를 사용했고,[7] 그 외의 보다 큰 메모리 모델에는 far pointer가 사용됐는데 당연히 near pointer가 오버헤드가 적어서 가급적이면 작은 메모리 모델을 골라서 사용했다. 게다가 포인터가 깨지기라도 하면 아예 컴퓨터가 맛 가는 경우도 많았기에 디버깅 지옥이었다. 본격적으로 32비트 시대가 열리고 난 이후에[8] near, far 개념이 사라지고 메모리 관리를 OS 차원에서 어느 정도 관리해 주면서 포인터 지옥에서 상당히 해방됐음에도 과거의 포인터의 악명이 그대로 이어져 오고 있는 것이다.[9] 물론 드라이버와 같은 Low-Level 프로그램을 짠다면 포인터 한 번 잘못 사용하는 것으로 블루 스크린을 심심찮게 맛볼 수 있다.[10]
포인터의 난이도와 악명은 C에 입문하는 대학생들에게 필요 이상의 심리적 부담을 안겨주어 시작하기도 전에 지레 겁부터 먹게 만드는 역효과를 가져왔다. 수많은 전자 공학, 컴퓨터 공학과 대학생 1학년들이 여기서 좌절하고, 전공을 바꾸려 한다. 특히 C의 포인터에서 가장 악마 같은 건 포인터와 배열을 왔다 갔다 하는 장난질. 여기에 교수들의 C에 대한 이해 부족과 잘못된 커리큘럼은 포인터를 더욱 큰 벽으로 느끼게 만들고 있다. 이 문제는 수십 년이 흘러도 개선되지 않고 있으며 결국은 조금 더 쉬운 언어인 자바나 C#, 훨씬 쉬운 파이썬으로 프로그래밍 교육 커리큘럼이 이동하고 있다.
자바를 비롯한 현대 언어들은 포인터를 아예 없애 메모리에 직접적인 접근을 막고 그 자리에 참조자를 넣어서 자동으로 관리한다. C의 포인터를 배우면 자바의 참조 변수나 얕은 복사/깊은 복사 같은 주제들을 좀 더 쉽게 이해할 수 있기는 하다. 자세한 건 Java 항목 참조. 파이썬도 비슷한 상황이다.
8. 포인터 공부 조언
우선은 변수 이름의 범위(스코프)와 변수의 생명 주기에 대해 철저하기 이해를 해야 한다. 포인터를 왜 써야 하는지 이해하기 위해 반드시 필요한 선행 개념들이며, 동적 메모리 할당과 관리를 안전하게 하기 위해 정확하게 알고 있어야 한다.포인터를 수식 내에서 사용할 때에는, 포인터 변수에 대해 * 연산자, & 연산자,
[]
연산자, -> 연산자 등을 적용했을 때 어떤 타입으로 변화해 나가는지를 올바로 추적할 수 있어야 한다. 또한 포인터에 대해서는 증감 연산자 또한 사용이 가능한데, 어떤 타입들끼리 더하거나 뺄 수 있고 그 결과 어떤 타입의 결괏값이 나오는지, 그 과정에 어떤 제한이 있는지도 파악해야 한다.포인터의 문법에 대해 어느 정도 학습했다면, 그다음은 하드웨어와 관련된 부분들을 이해해야 한다. 기계마다 다른 int형(word)의 크기와 빅/리틀 엔디언, 메모리 정렬, 레지스터, 캐시 등의 하드웨어와 관련된 개념을 이해하고, 더 나아가 C의 추상 기계 규칙과 실제 기계와의 차이, 그로 인한 보안 버그와 최적화 이슈까지 파악해야 한다. 정 어려우면 컴퓨터 구조론[11]이나 어셈블러에 대해 따로 공부해 보는 것도 도움이 된다.
포인터를 가지고 실제 프로그램을 작성하는 단계에서는, 객체의 수명을 관리하는 것에 각별히 주의를 기울여야 한다. 보안 버그의 70% 이상은 메모리 안전 문제이다. 소멸한 객체를 다시 소멸시키는 것, 소멸된 객체에 접근하는 것, 포인터 주소 연산의 오류로 엉뚱한 위치에 접근하는 것, 주어진 메모리 공간에 대해 허용되지 않는 잘못된 타입으로 접근하려 하는 것, 배열에서의 경계선 침범 등과 같은 문제들이 복잡한 코드와 동작들 사이에서 얼마든지 나타날 수 있다.
9. 다른 언어/분야에서
다른 언어에서는 포인터를 지원하지 않는다. 대표적으로 Java나 C# 같은 매니지드(managed, 알아서 메모리를 관리해 주는) 언어들은 기본적으로 객체 참조 기반으로 동작해서 포인터를 지원하지 않는다. 참조에 관련된 일은 저단계에서 알아서 해주기 때문에 쌩 포인터를 잡고 낑낑거리기보다는 쉽다.[12] (결국 구조체와 배열 참조도 전부 포인터 참조로 이루어진다. 다만 사용자에게 제공하지 않을 뿐이다.) 이게 뭐가 문제냐면 시스템이 복잡해지고 입체적으로 확장되다 보면 수많은 포인터의 사용 문제가 결국 관리 비용의 엄청난 증가로 이어지기 때문이다. 프로그래머의 각자 역량에 관리를 맡겨버리는 포인터의 심플함은 결국 대규모 프로젝트의 생산성 저하로 이어지고 알 수 없는 오류에 대한 관리 비용 증가로 직결된다. 그래서 Java나 C#과 같은 메니지드 언어에서는 포인터를 시스템 레벨로 올려서 은폐시키고 프로그래머에게는 가비지 컬렉션 같은 간접적인 노출만 허용하게 됐다. 프로그래머가 직접적으로 메모리를 다루지 않아 오류 상황에서 OS 차원의 셧다운(강제 종료)은 없어져서 운용 면에서 안정성이 크게 증가됐으나, 퍼포먼스 저하와 더불어 엄청난 오버 헤드를 감당하게 됐다. Java나 C#에서 제공하는 가비지 컬렉션의 악명은 설명하지 않아도 알게 될 것이다. 매우 불편하고 느리며 통제가 쉽지 않다. 다만 성능 문제는 현시점에서 크게 문제가 되지 않는데, Java나 C#이 주로 사용되는 곳은 데스크톱 애플리케이션이 아닌 웹 서버 프로그래밍 쪽이다. 이쪽은 언어 자체의 퍼포먼스보다 데이터베이스의 쿼리 속도가 더 크게 성능을 좌우한다.
파이썬에서는 ctypes 모듈에서 포인터를 지원한다. C 자료형밖에 안 되지만 숫자, 문자열은 메모리 공유가 되지 않는 파이썬 특성상 필요하기도 하다. 다만 멀티 프로세싱에서는 불가능.
초기 C에서 분화해 나간 C++의 경우에도 초기 C 문법을 슈퍼셋으로 가지고 있으므로 일단은 포인터 사용이 가능하지만, 사실상 레퍼런스나 다름없는 스마트 포인터의 사용으로 C 방식의 포인터 사용 필요성을 점점 배제하는 방향으로 가고 있다.
기계 제어가 아닌 분야에서는 굳이 포인터에 목매달 필요가 없다. 포인터로 온갖 문제를 야기하느니, 포인터를 아예 없애는 게 더 효율이 높다고 판단하기 때문이다. 그래서 최신 언어인 경우 프로그래머는 포인터를 직접 다룰 수 없거나, C#처럼 포인터를 지원하더라도 사용을 권장하지는 않는다. 내부적으로는 포인터를 사용하겠지만, 외부적으로는 프로그래머가 직접 포인터를 다룰 필요가 없도록 숨겨 놓은 것이다.
레퍼런스(참조자, 참조 변수)와 포인터는 비슷한 점이 많고, 다른 언어들은 숫자로 포인터를 다루지 않을 뿐, Symbol로 포인터를 다룬다고 해석할 수도 있다. 마찬가지로 다른 언어에서도 배열에 Index를 통해 접근하는 방법들이 있는데 이 경우도 일종의 포인터라고 볼 수 있다.
위에서 나온 포인터를 다루는 다른 방법이 어려워 보여도, 어떤 프로그래밍 언어든지 객체들 사이의 Shape를 생각하지 않거나 배열이나 행렬의 차원을 생각하지 않고 접근하려고 하면 에러가 나오는 것은 마찬가지이고, 마찬가지로 다른 언어에서도 참조에 참조를 조합해야 하는 재귀적 자료 구조나, 함수에 함수를 조합해야 하는 고차 함수 등의 개념을 잘 활용하는 개발자들은 C에서 나오는 복잡한 포인터 문법도 이해하는 데 어렵지는 않을 것이다.
다만 C에서는 포인터에 대해 추상화 정도가 낮기 때문에 위에서 말한 이식성 문제 같은 조금 복잡한 문제가 있다고 보면 된다.
[1]
그러나 이는 매우 주의 깊게 사용되어야 하는데, 최적화와 캐시의 동작 때문에 의도하지 않은 순서와 동작으로 메모리 접근이 이루어질 수 있기 때문이다. volatile 키워드는 어느 정도 도움이 되기도 하나 완벽한 해결책은 되지 못한다. 따라서 관련 기능을 활용한 코드는 매우 주의 깊게 다루어져야 하며 구현체의 변화(컴파일러의 버전 업 등)에 따라 꾸준히 추적 관찰 되어야 한다.
[2]
게임의
치트 엔진이 비슷한 예시다. 치트 엔진으로 게임 프로세스가 사용 중인 메모리에 직접 접근해 게임 내 변수의 값을 조작하는 게 바로 포인터의 접근 방식이다.
[3]
아무리 섹터 단위로 읽고 쓴다지만 결국 물리적으로 뺑뺑이 돌아야 한다.
[4]
함수에 대해서는 sizeof 연산자를 쓸 수 없으며, 이 경우에는 자동 변환 규칙 또한 적용되지 않는다.
[5]
흔히 최약체 중 하나인
연결 리스트 정도에서 첫 번째 좌절을 마주한다.
[6]
사실 8비트 CPU도 마찬가지지만.
[7]
near pointer는 offset만 저장한다. data segment가 한 개뿐인 상황에서는 offset 이상의 정보가 필요가 없으니.
[8]
사실 32비트 시대에서도 DOS는 호환성 문제로 기본적으로 16비트 프로그래밍을 이용했어서 큰 의미가 없었다. 이 점은 16비트 버전 윈도우도 마찬가지.
[9]
Intel CPU에서 16비트 모드의 경우, seg:ofs로 나뉘는데, 이가 가리키는 주소는 addr = (seg << 4) | ofs;로 표현되며 seg, ofs 둘 다 16비트여서 이들의 값이 달라도 같은 주소를 가리키는 경우가 많다. 예를 들어서, 22eeh:0000h = 22e0h:00e0h = 2200h:0ee0h = ....
[10]
참고: 드라이버 개발자가 아닌 일반적인 사용자의 코딩 실수로 인한 블루 스크린 문제는 32비트 OS로 완전히 패러다임이 전환된 Windows XP 이후로는 보기 어렵다. 즉 예전처럼 유저 모드의 포인터 오류로 시스템이 셧다운되는 상황은 발생하지 않는다. 제대로 된 32비트 OS들부터는 CPU가 항상 보호 모드(Protected Mode)로 동작해서 사실 유저 모드에서는 무슨 수를 써도 메모리의 실제 물리적 주소에 접근할 수 없으며, 따라서 아무리 포인터로 잘못된 행동을 하더라도 하드웨어 패닉이 발생하지 않고 CPU 수준에서 OS에게 잘못된 메모리 접근을 처리하라는 예외 인터럽트를 던져주기 때문이다.
[11]
대학교 2학년 쯤에 전공과목으로 배울 것이다.
[12]
C#은 unsafe 키워드로 정말 정말 간절하게 필요한 경우 유사하게 만들어 사용할 수 있다. Java에도 Unsafe 클래스가 있긴 하지만 팩토리 메소드가 막혀있어 리플렉션을 사용하거나 Java Native Access 라이브러리를 사용해야 쓸 수 있다. 게다가 Java에서 굳이 포인터를 직접 관리해 줄 필요는 없다.