모든 언어의 아버지 격인 C를 만든 데니스 리치 & 브라이언 커니핸의 저서는 “The C programming language”입니다. 이 책의 차례에만 “pointer” 단어가 10번 이상 나옵니다.
그만큼 중요한 개념입니다. 다른 모든 언어를 이해할 때도 포인터가 가장 기초적 개념이 됩니다. 심지어 이기종의 언어들(JAVA, SWIFT 等)도 포인터 개념으로 이해할 수 있습니다.
포인터
포인터를 만드는 키워드는 * (Asterisk, 애스터리스크)입니다. point는 “가리키다”는 뜻이며, pointer는 “가리키는 것”입니다. 명확한 정의는 “메모리의 특정 주소를 가리키는 것”입니다. [주소]를 가리키기에 보통 아파트 [주소]에 비유합니다.
메모리 주소의 경우
0xb000001
로 표현하고
아파트 주소의 경우
수원시 영통구 영통로 154번길 자바아파트 108동 1004호
의 식으로 표현 합니다.
포인터와 아파트 주소의 다른 점이 있습니다.
포인터의 경우 아파트 평수도 고려를 해야 한다는 것입니다.
포인터 변수의 크기는 우편번호와 같이 고정 길이로 되어 있습니다.
printf("%d %d\n", sizeof(char), sizeof(char*));
printf("%d %d\n", sizeof(short), sizeof(short*));
printf("%d %d\n", sizeof(int), sizeof(int*));
printf("%d %d\n", sizeof(long), sizeof(long*));
printf("%d %d\n", sizeof(float), sizeof(float*));
printf("%d %d", sizeof(double), sizeof(double*));
백설표 설탕 마크(*)가 붙은 모든 변수는 타입에 관계없이 32bit 컴파일 時 4byte, 64bit로 컴파일 時 8byte 입니다. 왜냐면 특정값을 말하는 것이 아니라 메모리 주소값을 말하기 때문입니다. 프리미티브 변수의 경우 변수 자체가 "변수에 들어있는 값"을 의미합니다.
int s = 88;
int *o = &s;
printf("%d, %d, %d, %x, %d", s, o, &s, &o, *o);
포인터 변수 o 에 변수 s 의 메모리 주소값을 담고 있습니다. 즉 88=s=*o 입니다. &s = o 입니다. &o 의 경우 포인터 변수 o 자체의 주소를 말합니다. 메모리 특정 공간에 4byte, 64머신이라면 8byte의 공간을 차지하고 있습니다.
모든 프로그램은 메모리에서 실행이 되기 때문에 포인터는 그 이름 그대로 메모리의 모든 공간을 가리킬 수 있습니다. 다만, 얼마만큼의 공간을 가리키는지 정해줘야 합니다. int 형의 경우 int형 포인터로 선언해서 같은 공간을 가리킬 수 있습니다. char 형의 경우 char 포인터 변수를 이용해서 가리키면 됩니다. int와 long 형 모두 4byte 이므로 int형을 long 형 포인터로 가리켜도 관계 없습니다. 아파트 주소와는 다르게 아파트 평수도 함께 기입을 해 줘야 합니다. 즉, int *s; 의 경우 *s 부분만 아파트 주소를 말합니다.
수원시 영통구 영통로 154번길 자바아파트 108동 1004호 (24평)
처럼 평수를 기입하는 것이 바로 *s의 앞부분에 있는 int 입니다.
이처럼 int 형은 안다는 것은 메모리의 개념은 연속적인 것이라서 특정 부분까지 읽어야 한다는 뜻입니다. 예를 들어, “아버지방구” 를 메모리에 적재했을 때 다음과 같이 연속적 공간에 적재된다고 봅시다.
|아|버|지|가|방|구|
3글자를 읽으면 아버지가 되고
5글자를 읽으면 아버지가방이 되고
6글자를 읽으면 아버지가방구 가 됩니다.
읽는 크기에 따라서 얻을 수 있는 값이 달라진다는 뜻입니다.
포인터 연산자
포인터 관련 연산자는 단 두가지 입니다. & 의 경우 주소값을 말하며, *의 경우 값을 말합니다.
둘은 정반대의 개념입니다.
int s = 88;
printf("%d", *&*&*&*&*&*&*&*&*&*&*&s);
이와 같은 경우 printf 안에 &*...s 는 단순히 s를 적어 준 것과 같습니다.
리틀 엔디안, 빅 엔디안
CPU는 무조건 둘 중 하나의 바이트 오더를 가집니다. 옵션으로 선택을 할 수 도 있고, 처음부터 고정되어서 나오기도 합니다. 여기서 unit(단위)이 byte 단위라는 것이 가장 중요한 포인트 입니다. int 형 4바이트(혹은 8바이트)수를 char 형 포인터 변수로 읽는다면 앞 부분 혹은 뒷 부분의 일부분 밖에 읽을 수 없습니다. 앞 부분/뒷 부분의 방식은 CPU의 endian 방식에 따라 달라집니다. 빅 엔디안의 경우 우리가 생각하는 그대로의 모습입니다. 그러나 리틀 엔디언의 경우 그 반대 입니다.
int n = 0x11223344;
printf("%x\n", (char)n);
의 경우 44가 남습니다.
데스크톱 컴퓨터에서 가장 많이 쓰는 Intel CPU의 경우 리틀 엔디언 입니다. 0x 로 시작하는 16진수 숫자 하나의 경우 4비트를 표현할 수 있습니다. 8비트가 1바이트니 “44”라는 2개의 숫자가 나오는 것입니다.
포인터 변수의 경우 메모리의 주소 값을 가지고 있는 특수 목적 변수입니다.
&p의 경우 포인터 변수 자체의 주소값을
p의 경우 가리키는 곳의 주소값을
*p의 경우 가리키는 곳의 주소에 담긴 실제 값을 가리킵니다.
CPU의 특성에 모든 언어는 영향을 받습니다.
심지어 플랫폼에 종속적이지 않다는 JAVA, 전세계에서 가장 많이 쓰는 운영체제인 Android 조차 데이터 구조를 설계하고 네트워크로 보내는 부분이 들어가면 엔디안 변환도 필요한 프로젝트가 있습니다. 운영체제도 CPU가 제공하는 API로 만들어진 프로그램에 불과하기에 그 위에 만들어진 프로그램도 마찬가지로 영향을 받습니다. 프레임워크라고 거창하게 불리는 것도 하나의 프로그램일 뿐입니다.
다중 포인터
주소값을 대입할 때 다음과 같이 중첩해서 대입할 수 있습니다.
int s = 88;
int *o, **o2o, ***o2o2o, ****o2o2o2o, *****o2o2o2o2o, ******o2o2o2o2o2o;
o = &s;
o2o = &o;
o2o2o = &o2o;
o2o2o2o = &o2o2o;
o2o2o2o2o = &o2o2o2o;
o2o2o2o2o2o = &o2o2o2o2o;
printf("%d\n", ******o2o2o2o2o2o);
배열 포인터
int s[3][6] = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17 };
int (*o)[6] = s;
printf("%d ", o[1][1]);
배열 포인터라는 단어에서 앞부분은 “배열”, 뒷부분은 “포인터” 입니다.
복잡한 배열과 포인터의 해석에서는 뒷 단어만 보면 됩니다.
배열 포인터는 포인터 입니다.
소스 코드는 어떻게 해석할까요? 변수명에 *가 있으면 포인터 []가 있으면 배열 입니다. 변수명이 ()로 싸여져 있다면 () 안만 보면 됩니다. *[]가 같이 있다면 우측에 붙는 []를 우선시 합니다.
int ( * read ) ( struct block_device *blockdev, uint64_t block, ...) //(함수) 포인터 입니다.
int *s[8]; //배열 입니다.
int (*s)[8]; //포인터 입니다.
int *(s[8]); //배열입니다.
말로 장황하게 설명된 부분을 볼 때 마지막 단어만 보면 됩니다. 배열에 포인터에 포인터에 배열에 포인터의 배열이라고 하면… 가장 뒤에 배열이 있으니 “배열” 입니다. 배열 포인터는 배열을 가리키는 포인터 입니다. 가리킬 때 “아파트 평수”를 같이 말해줘야 합니다. 아파트 평수는 별로 선호하는 비유가 아니므로 이후부터는 “메모리 공간을 바라보는 크기”, 줄여서 “크기”라고 표현 하겠습니다. 배열의 크기가 6이므로 *o는 6개의 단위로 끊어서 메모리를 바라보게 됩니다. printf 값은 7이 나오게 됩니다. 그럼, 항상 이렇게 값을 알려줘야 할까요? “아버지가방구”처럼 마음대로 읽고 싶을 수도 있습니다. 물론, 프로그래머 마음 입니다.
int s[3][6] = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17 };
int **r = s;
printf("%d", *(int*)r+15);
이와 같이 대입 후 r 의 크기(메모리 공간을 바라보는 크기, 아파트 평수, 아버지방구)를 int 형으로 지정 후에 끊어서 읽으면 됩니다. 15번째 값이므로 15가 출력 됩니다.
printf("%d", *(int*)r+15); 는 다음과 같이 바꿀 수도 있습니다.
printf("%d", ((int*)r)[15]);
[] 는 +와 * 의 역할을 합니다.
포인터 배열
int s = 88;
int *o, **o2o, ***o2o2o, ****o2o2o2o, *****o2o2o2o2o, ******o2o2o2o2o2o;
o = &s;
o2o = &o;
o2o2o = &o2o;
o2o2o2o = &o2o2o;
o2o2o2o2o = &o2o2o2o;
o2o2o2o2o2o = &o2o2o2o2o;
printf("%d\n", ******o2o2o2o2o2o);
int *p[6] = { o, o2o, o2o2o, o2o2o2o, o2o2o2o2o, o2o2o2o2o2o };
printf("%d\n", s);
printf("%d\n", *p[0]);
printf("%d\n", *(int*)*p[1]);
printf("%d\n", *(int*)*(int*)*p[2]);
printf("%d\n", *(int*)*(int*)*(int*)*p[3]);
printf("%d\n", *(int*)*(int*)*(int*)*(int*)*p[4]);
printf("%d", *(int*)*(int*)*(int*)*(int*)*(int*)*p[5]);
위에서 설명한 것과 같이 “포인터 배열”의
뒷 부분만 읽으면 “배열” 입니다.
포인터를 담는 배열로 생각하면 되겠습니다. 포인터를 하나씩 꺼내어 참조했던 값들을 따라가서 모두 88이 출력되는 프로그램 입니다.
동적 할당
int *a = (int*)calloc(9, sizeof(int));
int y[8] = {0, 1, 2, 3, 4, 5, 6, 7};
for (int i = 0;i < 8;i++) {
a[i] = y[i];
}
printf("%d\n", y[6]);
printf("%d\n", a[6]);
y[6] = 88;
printf("%d\n", y[6]);
printf("%d\n", a[6]);
free(a);
a = y;
printf("%d", a[6]);
동적 할당은 메모리 공간 확보로 이해하면 됩니다. 성능이 크게 중요하지 않다면 malloc 보다 calloc를 사용하는 것을 권장 합니다.. 메모리 공간을 확보하고 배열을 이용하여 값을 복사합니다. 포인터 변수 a는 단지 확보된 메모리를 가리키는 변수일 뿐입니다. 메모리를 해제하고 a를 y가 가진 메모리를 가리키고 바뀐 값을 보여주는 예제 입니다.
함수 포인터
지금까지는 포인터와 배열만 나누었습니다. 왜냐면 메모리의 입장에서는 배열, 포인터, 함수포인터, struct, class 모두 같은 개념 입니다. 메모리에서 바라보는 "크기"의 문제입니다. 프로그래머도 메모리로 생각하는 눈을 가진 다면 데이터와 함수, 멤버변수와 메소드, struct와 function 모두 같은 개념으로 다가 옵니다.
배열은 데이터를 편하게 넣고 읽기 위해, 포인터는 프로그램을 유연하게 만들기 위한 목적으로 주로 사용됩니다. 특정 주소에 bit 값을 써야하는 임베디드 프로그래밍 영역을 제외하고는 객체지향을 보는 목적으로 함수 포인터를 보면 되겠습니다.
함수 포인터는 객체 지향의 주요 구성 요소 입니다. struct 와 typedef 를 이용하여 간단히 객체 지향 패턴을 구현해 보면 다음과 같습니다.
......
......
......
씨와 자바의 공통점
객체지향언어라는 공통점을 가지고 있는 씨뿔뿔과 자바의 공통점을 찾기는 어렵지 않습니다. C 와 JAVA의 공통점을 하나하나 짚어보면서 다른 프로그래밍 언어의 공통점을 알고 소통하는 프로그래밍을 해 보도록 합시다.
Null Pointer Exception
C는 포인터를 가지고 있습니다. 포인터 값에 null 이 배정되고, null 포인터로 발생하는 문제 상황을 null pointer exception 이라고 합니다. null pointer exception 은 null pointer error 라고도 말을 합니다. error가 exception을 포함하는 개념이기 때문입니다. 이에 모든 error가 exception은 아니지만, 모든 exception은 error 입니다. error 와 exception 모두 프로그램을 종료 시킬 수 있기에 크게 구분하지는 않습니다. exception이 error 보다 작은 개념이고 개발자가 컨트롤 할 수 있는 부분이라고 보시면 됩니다.
#include
void main() {
int *s = NULL;
*s = 8;
}
이는 다름과 같이 Null Pointer Exception을 일으킵니다.
아주 오래전 개발되었던 “씨앗" 언어 이후에는 한글로 된 소스를 찾아보기 어려웠습니다. JAVA의 창시자인 James Gosling 의 저서 [The Java Language Specification] Chapter 3. Lexical Structure에 전세계 언어를 지원하는 UTF-16 이야기가 나옵니다. 이에 한글로 클래스 명을 작성하였습니다. 변수, 메소드 역시 한글 사용이 가능합니다.
package javatest;
public class 널포인터익셉션 {
public static void main(String[] args) {
Object s = null;
System.out.println(s.getClass());
}
}
이 프로그램을 실행시킨 결과는 다음과 같습니다.
Exception in thread "main" java.lang.NullPointerException
at javatest.널포인터익셉션.main(널포인터익셉션.java:6)
java.lang.NullPointerException 이라고 되어 있습니다. 포인터가 없는 언어인데 왜 Null Pointer 라는 표현을 쓸까요?
자바 언어의 소스 코드 중 JavaExceptions.c 파일을 보면
jthrowable
createThrowableFromJVMTIErrorCode(JNIEnv * jnienv, jvmtiError errorCode) {
const char * throwableClassName = NULL;
const char * message = NULL;
jstring messageString = NULL;
switch ( errorCode ) {
case JVMTI_ERROR_NULL_POINTER:
throwableClassName = "java/lang/NullPointerException";
break;
errorCode가 JVMTI_ERROR_NULL_POINTER 일 때 NullPointerException 메시지를 출력합니다.
C에서 NULL은 0 입니다. JAVA에서는 무엇일까요?
NULL_CHECK(e, JVMTI_ERROR_NULL_POINTER);
NULL_CHECK를 0인지 아닌지로 판별하고 있습니다. 역시 0 입니다.
#define NULL_CHECK0(e) if ((e) == 0) return 0
#define NULL_CHECK(e) if ((e) == 0) return
언어적 철학으로는 NULL(C11), null(JAVA), nil(Objective-C), nullptr(Visual C/C++) 등 다양하게 표현하고 있지만 실상은 모두 0 입니다. 개념 분리를 위해 각 언어들이 노력하지만 단순히 ‘없다’는 표현이 맞고, 0과 1의 컴퓨터 세계에서는 0으로 이해하는 것이 다양한 프로그래밍 언어를 이해하는 첫 걸음 입니다. JAVA 역시 C/C++로 짜여져 있습니다. C++ 창시자 비얀 스트라스트럽이 가장 먼저 만든 프로그램은 C++ 코드를 C로 만들어 주는 번역기 였습니다. 이미 근본적으로 C, C++, JAVA는 같은 언어일 수 밖에 없습니다. 우리는 프로그래밍 언어들을 꿰뚫는 개념을 보는 방법을 알아야 합니다.
Class와 Struct
C/C++ 프로그래머는 이미 class 와 struct의 공통점을 알고 있습니다. struct 의 경우 public 접근 제한, class의 경우 private 접근 제한을 가진다는 것 외엔 동일합니다.
#include
#include
struct Cpluscplus {
int age;
char name[16];
};
void main() {
Cpluscplus cppStruct;
cppStruct.age = 33;
strcpy_s(cppStruct.name, "이소라");
printf("%s(%d)", cppStruct.name, cppStruct.age);
}
struct 를 class로 고친다면 다음과 같이 고칠 수 있습니다.
#include
#include
class Cpluscplus {
public:
int age;
char name[16];
};
void main() {
Cpluscplus cppStruct;
cppStruct.age = 33;
strcpy_s(cppStruct.name, "이소라");
printf("%s(%d)", cppStruct.name, cppStruct.age);
}
자바의 경우 다음과 같습니다.
package civa;
public class 자바클래스 {
int age;
String name;
public static void main(String[] args) {
자바클래스 o = new 자바클래스();
o.age = 33;
o.name = "이소라";
System.out.println(o.name+"("+o.age+")");
}
}
age 와 name은 C, C++, JAVA에서 동일하게 멤버 변수로 불립니다.
class 안에 들어 있는 method는 어떤 개념으로 이해를 해야 할까요? setter/getter 가 들어가는 경우 struct와의 공통점을 어떻게 이해를 해야 할까요?
package civa;
public class 자바클래스 {
int age;
private String name;
public void setName(String name) {
this.name = name;
}
public String getName() {
return this.name;
}
//타 클래스에서 만들어진 main 으로 간주
public static void main(String[] args) {
자바클래스 o = new 자바클래스();
o.age = 33;
o.setName("이소라");
System.out.println(o.getName()+"("+o.age+")");
}
}
이 클래스는 C에서 이렇게 구현 됩니다.
댓글 없음:
댓글 쓰기
국정원의 댓글 공작을 지탄합니다.