크래프톤 정글 TIL : 0726

lazyArtisan·2024년 7월 26일
0

정글 TIL

목록 보기
26/147

📝 배운 것들


🏷️ switch 문 정수값만 오는 이유

switch 문의 '값' 부분에 왜 정수만 와야됨?

C 언어에서 switch 문의 부분에 정수형 데이터만 올 수 있는 이유는 C 언어의 설계 및 컴파일러 구현의 단순화와 효율성 때문입니다. 이는 다른 정수형 타입(char, int, enum 등)으로 제한됩니다.

이유들

1. 설계 단순성

C 언어는 시스템 프로그래밍 언어로 설계되었으며, 하드웨어와의 밀접한 상호작용을 고려하여 설계되었습니다. 정수형 데이터는 하드웨어 레벨에서 효율적으로 처리할 수 있는 타입입니다. 따라서, switch 문의 부분을 정수형으로 제한하면 언어의 설계가 단순해집니다.

2. 컴파일러 최적화

정수형 데이터를 사용하면 컴파일러가 switch 문을 효율적으로 최적화할 수 있습니다. 예를 들어, switch 문은 종종 "점프 테이블"을 사용하여 상수 값에 따라 직접 분기할 수 있습니다. 이 테이블은 정수형 값에 기반하여 인덱싱할 수 있으므로, 빠른 분기가 가능합니다.

3. 일관성 및 예측 가능성

정수형 데이터를 사용하면 switch 문은 일관되고 예측 가능하게 동작합니다. 부동소수점 숫자나 문자열과 같이 복잡한 타입을 사용하는 경우, 비교와 분기가 더 복잡해질 수 있습니다. 이를 통해 switch 문이 더 단순하고, 일관되게 동작할 수 있습니다.

예제: 정수형 switch

#include <stdio.h>

int main() {
    int value = 2;

    switch (value) {
        case 1:
            printf("Value is 1\n");
            break;
        case 2:
            printf("Value is 2\n");
            break;
        case 3:
            printf("Value is 3\n");
            break;
        default:
            printf("Value is something else\n");
    }

    return 0;
}

위의 예제에서 value는 정수형 변수이며, 각 case 레이블도 정수 상수입니다.

문자열이나 부동소수점 숫자 사용의 복잡성

문자열

문자열은 switch 문에서 사용할 수 없습니다. 문자열 비교는 정수 비교보다 훨씬 복잡합니다. 문자열 비교는 단순한 값 비교가 아니라, 각 문자마다 비교를 수행해야 합니다.

부동소수점 숫자

부동소수점 숫자는 이진 표현 때문에 정밀도 문제로 인해 비교가 어렵습니다. 두 부동소수점 숫자가 정확히 일치하는지 비교하는 것은 신뢰할 수 없는 결과를 초래할 수 있습니다.

예제: 문자열을 switch 문처럼 사용하기

문자열 비교를 사용하여 switch 문처럼 동작하는 코드를 작성할 수 있습니다. 이는 if-else 문을 사용해야 하며, 직접 문자열을 비교해야 합니다.

#include <stdio.h>
#include <string.h>

int main() {
    char *str = "two";

    if (strcmp(str, "one") == 0) {
        printf("String is one\n");
    } else if (strcmp(str, "two") == 0) {
        printf("String is two\n");
    } else if (strcmp(str, "three") == 0) {
        printf("String is three\n");
    } else {
        printf("String is something else\n");
    }

    return 0;
}

이 예제에서는 strcmp 함수를 사용하여 문자열을 비교합니다.

🏷️ 컴파일러가 switch문 최적화하는법

컴파일러는 switch 문을 분석하여 최적화 방법을 선택합니다. 이를 위해 case 값의 분포와 개수를 고려합니다. 예를 들어:

  • case 값이 연속적이거나 거의 연속적일 경우 점프 테이블이 사용될 수 있습니다.
  • case 값이 불규칙하게 배치된 경우 이진 탐색이 사용될 수 있습니다.
  • case 값이 적고, 간격이 크지 않은 경우 단순한 일련의 조건문이 사용될 수 있습니다.

🏷️ 배열에 쓰는 []의 비밀

  printf("a[3] : %d \n", arr[3]); // a[3] : 4 
  printf("*(a+3) : %d \n", *(arr + 3)); // *(a+3) : 4

arr[3] 이라 사용한 것은 사실 *(arr + 3) 으로 바뀌어서 처리가 된다

3[arr] : 4 
*(3+a) : 4

그래서 이것도 arr[3]이랑 똑같음

포인터 형변환

포인터 간의 형변환은 무엇을 의미하는가? 그리고, C 언어에서 포인터 간의 형변환이 위험한 것인가?

포인터 간의 형변환은 한 타입의 포인터를 다른 타입의 포인터로 변환하는 것을 의미합니다. 이는 C 언어에서 자주 사용되며, 주로 메모리를 효율적으로 관리하거나 특정 하드웨어에 직접 접근하기 위해 사용됩니다. 그러나 포인터 간의 형변환은 위험을 수반할 수 있으며, 이를 신중하게 다루지 않으면 심각한 버그나 예기치 않은 동작을 초래할 수 있습니다.

포인터 간의 형변환

포인터 간의 형변환은 캐스트 연산자를 사용하여 수행됩니다. 예를 들어, int* 타입의 포인터를 char* 타입의 포인터로 변환할 수 있습니다.

#include <stdio.h>

int main() {
    int a = 42;
    int* int_ptr = &a;
    char* char_ptr = (char*)int_ptr;  // 포인터 형변환

    printf("int_ptr: %p\n", (void*)int_ptr);
    printf("char_ptr: %p\n", (void*)char_ptr);

    return 0;
}

포인터 간의 형변환이 필요한 경우

  1. 메모리 관리:

    • 동적 메모리 할당 함수는 void*를 반환하므로, 이를 특정 타입의 포인터로 변환해야 합니다.
    int* array = (int*)malloc(10 * sizeof(int));
  2. 저수준 프로그래밍:

    • 하드웨어 레지스터에 접근하거나 네트워크 패킷을 처리할 때, 특정 메모리 레이아웃을 다루기 위해 포인터 형변환이 필요할 수 있습니다.
    unsigned int reg = 0x12345678;
    unsigned char* byte_ptr = (unsigned char*)&reg;
  3. 제네릭 프로그래밍:

    • 함수가 다양한 타입의 포인터를 처리하도록 하기 위해 void*를 사용한 후 적절한 타입으로 변환할 수 있습니다.
    void print(void* data, char type) {
        if (type == 'i') {
            printf("%d\n", *(int*)data);
        } else if (type == 'f') {
            printf("%f\n", *(float*)data);
        }
    }

포인터 간의 형변환이 위험한 이유

포인터 간의 형변환은 여러 가지 이유로 위험할 수 있습니다:

  1. 잘못된 메모리 접근:

    • 포인터를 잘못된 타입으로 변환하여 접근하면, 예상치 못한 메모리 위치를 읽거나 쓸 수 있습니다. 이는 메모리 오염, 프로그램 충돌, 심각한 보안 취약점을 초래할 수 있습니다.
    int a = 42;
    char* char_ptr = (char*)&a;
    printf("%c\n", *char_ptr);  // 예상치 못한 값 출력
  2. 데이터 정렬 문제:

    • 서로 다른 타입의 데이터는 메모리에서 서로 다른 정렬 규칙을 가질 수 있습니다. 포인터 형변환 후 올바르게 정렬되지 않은 데이터에 접근하면, 프로그램이 비정상적으로 동작할 수 있습니다.
    struct __attribute__((aligned(8))) AlignedStruct {
        int a;
        double b;
    };
    
    AlignedStruct s;
    char* char_ptr = (char*)&s;
    int* int_ptr = (int*)char_ptr;  // 정렬 문제 발생 가능
  3. 정확한 타입 정보 손실:

    • 포인터 형변환은 원래의 타입 정보를 잃게 만듭니다. 이는 특히 포인터를 여러 번 형변환하는 경우, 원래의 타입으로 복원하기 어려워질 수 있습니다.
    void* void_ptr = (void*)&a;
    int* int_ptr = (int*)void_ptr;
  4. 캐스팅 없이 포인터 타입 변환:

    • C 언어는 포인터 타입 간의 암묵적 변환을 허용하지 않습니다. 예를 들어, int*char*로 변환하려면 명시적 캐스트가 필요합니다. 명시적 캐스트 없이 변환을 시도하면 컴파일 오류가 발생합니다.
    int* int_ptr = &a;
    char* char_ptr = int_ptr;  // 컴파일 오류

안전하게 포인터 간의 형변환을 사용하는 방법

  1. 명시적 캐스트 사용:

    • 명시적 캐스트를 사용하여 포인터를 변환하고, 변환된 포인터가 올바르게 사용되는지 확인합니다.
    int* int_ptr = &a;
    char* char_ptr = (char*)int_ptr;
  2. 포인터의 원래 타입 기억:

    • 포인터를 형변환한 후, 가능한 한 빨리 원래 타입으로 되돌리는 것이 좋습니다.
    void* void_ptr = (void*)int_ptr;
    int* original_ptr = (int*)void_ptr;
  3. 정렬된 데이터 접근:

    • 데이터가 올바르게 정렬되었는지 확인하고, 정렬 규칙을 따릅니다.
    struct AlignedStruct {
        int a;
        double b;
    } __attribute__((aligned(8)));
  4. 메모리 접근 최소화:

    • 포인터 형변환을 최소화하고, 필요한 경우에만 사용합니다. 메모리에 직접 접근하기보다는, 함수나 구조체를 통해 간접적으로 접근하는 것이 좋습니다.

포인터는 어차피 8바이트로 다 똑같은데 왜 형변환하면 오류 생기는거임?

포인터는 메모리 주소를 저장하기 위해 사용되며, 대부분의 현대 시스템에서 64비트 주소 공간을 사용하기 때문에 포인터의 크기는 8바이트(64비트)입니다. 그러나 포인터의 크기와 관계없이 포인터 간의 형변환이 위험하고 오류를 일으킬 수 있는 몇 가지 중요한 이유가 있습니다.

1. 메모리 접근 방식의 차이

각 포인터 타입은 해당 타입에 맞게 메모리를 읽고 쓰는 방식을 정의합니다. 예를 들어, int*는 4바이트 정수를 가리키고, char*는 1바이트 문자를 가리킵니다. 포인터의 형변환은 이러한 메모리 접근 방식을 변경하게 됩니다.

#include <stdio.h>

int main() {
    int a = 0x12345678;
    char* p = (char*)&a;

    printf("a: %x\n", a);
    printf("*p: %x\n", *p);  // 첫 번째 바이트 출력 (리틀 엔디안 시스템에서 0x78)

    return 0;
}

위 예제에서 int 포인터를 char 포인터로 변환하면, a의 첫 번째 바이트만 접근할 수 있습니다. 메모리 접근 방식이 달라져서 전체 int 값을 올바르게 읽지 못하게 됩니다.

2. 데이터 정렬 문제

데이터 정렬(alignment)은 메모리 접근 성능을 최적화하기 위해 사용됩니다. 특정 타입의 데이터는 메모리의 특정 주소에서 시작해야 효율적으로 접근할 수 있습니다. 예를 들어, 4바이트 정수는 4의 배수 주소에서 시작하는 것이 일반적입니다.

#include <stdio.h>

struct Aligned {
    char a;
    int b;
};

int main() {
    struct Aligned s;
    s.a = 'A';
    s.b = 0x12345678;

    char* p = (char*)&s.b;
    printf("p[0]: %x\n", p[0]);  // b의 첫 번째 바이트 출력 (리틀 엔디안 시스템에서 0x78)

    return 0;
}

위 예제에서 s.b는 4바이트 정수로, 정렬된 주소에서 시작해야 합니다. char*로 변환하면 정렬 규칙이 무시되어 잘못된 메모리 접근을 일으킬 수 있습니다.

3. 타입 안정성

타입 안정성(type safety)은 프로그래밍에서 중요한 개념으로, 변수의 타입이 코드 전체에서 일관되게 사용되는 것을 보장합니다. 포인터 형변환은 이 타입 안정성을 깨뜨릴 수 있습니다.

#include <stdio.h>

void process(void* data, int type) {
    if (type == 1) {
        int* int_ptr = (int*)data;
        printf("Integer: %d\n", *int_ptr);
    } else if (type == 2) {
        float* float_ptr = (float*)data;
        printf("Float: %f\n", *float_ptr);
    }
}

int main() {
    int a = 10;
    float b = 3.14f;

    process(&a, 1);
    process(&b, 2);

    return 0;
}

위 예제에서 process 함수는 타입에 따라 포인터를 올바르게 형변환하여 사용합니다. 그러나 잘못된 타입을 전달하면 예상치 못한 동작이나 오류가 발생할 수 있습니다.

4. 포인터 산술

포인터 산술(pointer arithmetic)은 포인터에 정수 값을 더하거나 빼서 메모리 주소를 조작하는 것입니다. 포인터의 타입에 따라 이 연산이 달라집니다.

#include <stdio.h>

int main() {
    int arr[5] = {1, 2, 3, 4, 5};
    int* int_ptr = arr;
    char* char_ptr = (char*)arr;

    printf("int_ptr[1]: %d\n", int_ptr[1]);  // 2 출력
    printf("char_ptr[4]: %d\n", char_ptr[4]);  // 2 출력

    return 0;
}

위 예제에서 int_ptrint 타입의 포인터이므로, int_ptr[1]은 배열의 두 번째 정수(4바이트 단위)를 가리킵니다. char_ptrchar 타입의 포인터이므로, char_ptr[4]는 배열의 다섯 번째 바이트를 가리킵니다. 포인터의 타입이 달라지면 산술 연산의 결과도 달라집니다.

결론

포인터는 메모리 주소를 저장하는 변수이므로, 모든 포인터가 동일한 크기를 가지는 것은 맞습니다. 그러나 포인터 형변환은 다음과 같은 이유로 위험할 수 있습니다:

  1. 메모리 접근 방식의 차이: 포인터 타입에 따라 메모리를 읽고 쓰는 방식이 다릅니다.
  2. 데이터 정렬 문제: 정렬 규칙이 무시되면 잘못된 메모리 접근이 발생할 수 있습니다.
  3. 타입 안정성: 포인터 형변환은 타입 안전성을 깨뜨릴 수 있습니다.
  4. 포인터 산술: 포인터 타입에 따라 산술 연산의 결과가 달라집니다.

이러한 이유로 포인터 간의 형변환을 사용할 때는 신중해야 하며, 잘못된 사용은 심각한 버그와 예기치 않은 동작을 초래할 수 있습니다. 타입 안정성을 유지하고, 메모리 접근 방식을 명확히 이해하며, 올바른 정렬 규칙을 준수하는 것이 중요합니다.

📦 자료 구조 구현 과제

📌 Q1_A_LL

유저에게 정수를 요청하고 링크드 리스트에 오름차순으로 삽입하는 C 함수 insertSortedLL()를 만들어라.
insertSortedLL()은 링크드 리스트에 이미 있는 정수를 넣으면 안된다 (중복 허용 안됨)
새로운 요소가 삽입된 index 위치를 반환해야 한다.
함수가 제대로 작동하지 않으면, -1을 반환해야 한다.
링크드 리스트는 정렬되어 있거나 비어 있어야 한다.

int insertSortedLL(LinkedList *ll, int item);

현재 연결 리스트가 2,3,5,7,9라면 8 넣으면 2,3,5,7,8,9가 돼야 한다.
인덱스 4에 들어간 것도 출력해야됨.

현재 연결 리스트가 5,7,9,11,15라면 7 넣으면 -1 출력해야됨.
인덱스 -1에 7 넣어야 함.

int insertSortedLL(LinkedList *ll, int item)
{
	ListNode *cur;
	cur = (ListNode *)malloc(sizeof(ListNode));
	cur->item = item;

	// head에 아무것도 없으면 헤드에 넣기
	if (ll->head == NULL) {
		ll->head = cur;
		ll->size = 1;
		return 0;
	} else if(ll->head->item > cur->item){
		cur->next = ll->head;
		ll->head = cur;
		ll->size++;
		return 0;
	} else if (ll->head->item == cur->item) {
		free(cur);
		return -1;
	} else if (ll->size == 1) {
		ll->head->next = cur;
		ll->size = 2;
		return 1;
	} 
	// 다음 노드가 넣으려는 노드보다 큰 노드를 찾을 때까지 탐색
	int len = ll->size;
	ListNode *ptr = ll->head;
	for (int i = 0; i < len-1; i++) {
		int nextNodeItem = ptr->next->item;
		int curItem = cur->item;

		// 다음 노드 값이 넣으려는 노드보다 크다면 
		if(nextNodeItem >= curItem) {
			// 다음 노드 값이 자신과 같다면 -1 리턴
			if(nextNodeItem == curItem) {
				free(cur);
				return -1;
			}
			cur->next = ptr->next;
			ptr->next = cur;
			ll->size++;
			return i;
		}
		ptr = ptr->next;
	}

	// 마지막 노드까지 탐색했는데 아무것도 없었다면
	ptr->next = cur;
	ll->size++;
	return len;
}

맨 마지막 ptr->next = cur;ptr->next->next = cur;로 적었었다.
for문 안에 있는 마지막 ptr = ptr->next 때문에 원하는 곳보다 한 곳보다 작동하지 않았었다.
그거 때문에 동기들이 한참 코드 봐줌.

다음 노드가 아니라 이번 노드를 확인하고 이전 노드는 저장하면서 가져가는 식으로 했으면 코드가 훨씬 간결해졌을듯. 예외처리를 너무 많이 해버렸다.

0개의 댓글