[C] C lang 기본문법2

함수

  • C의 함수 선언은 C#과 거의 동일
  • 다만 C는 접근 제어자(예: public, private 등)가 없음.
public void honk (void) /* 컴파일 오류 */
{
  printf("hlloe")
}
  • C의 함수는 기본적으로 모두 전역(global)함수
  • 즉, C의 함수는 어디서든 호출할 수 있음.
  • 물론 이걸 제한할 수 있는 키워드도 있다.



함수의 오버로딩

  • C언어는 그런거 없다.
  • 따라서 함수 명을 다르게 만들어야 한다.

만약 A, B 아래 코드가 있다고 치면

A.

#include <stdio.h>

int main(void)
{
  foo();
  getchar();
  return 0;
}

void foo(void) /* 컴파일 오류 */
{
  printf("foo called");
}

B.

#include <stdio.h>

int main(void)
{
  int x = foo();
  getchar();
  return 0;
}

void foo(void) /* 컴파일 오류 */
{
  printf("foo called");
}

-> A는 컴파일이 안되고 B만 컴파일이 가능하다.

  • C는 언제나 위에서 아래로 코드를 훑음.
    • 함수 정의가 등장하기 전에, 즉 함수를 구현해 놓은거. 실제 중괄호 열고 거기다 코드 집어 넣고 이런 함수를 보기전에 이 함수를 호출하려고 하면 원칙상은 그함수를 모르므로 나 몰라라고 하는게 정상. 근데 C89에서 컴파일러가 이런 경우를 마주치면 그냥 가정을 자기 멋대로 생각해 봄.
  • ANSI C (C89)에서 함수 정의(definition, 구현부와 같은 의미)가 등장 하기 전에 그 함수를 호출하면 컴파일러가 다음과 같이 가정
    • 반환형은 int
    • 그 매개변수는 아무거나 올 수 있다.
  • 따라서 나중에 컴파일러가 int 가 아닌 다른것을 반환하는 함수를 찾으면 컴파일 오류를 뱉음.

해결법

  • 호출 전에 함수 정의를 위치시키면 아무 문제가 없다.

컴파일 실패 코드

#include <stdio.h>

int main(void)
{
  foo();
  getchar();
  return 0;
}

void foo(void) /* 컴파일 오류 */
{
  printf("foo called");
}

올바른 코드


void foo(void) /* 컴파일 오류 */
{
  printf("foo called");
}


int main(void)
{
  foo();
  getchar();
  return 0;
}




함수 정의

  • 그럼 함수 만들때 마다 호출하는 코드 위에 다 위치해야 하나?
  • 함수 100개면 다 위에 넣어야 함? 그럼 코드가 되게 지저분해지는데.
  • 함수에서 다른 함수 호출하는 경우는?

요즘 컴파일러나 새로운 코드 컴파일러나 툴처럼 소스코드 분석해서 아래에서 봤으니까 호출해줄게 이런식이 아님.

이걸 해결하기 위한게 함수 선언 이다.



함수 선언

  • 함수의 구현체 없이 함수원형(prototype)만 선언해주는 것
  • 함수의 원형은 다음의 사항들을 명시
    1. 함수의 이름
    2. 반환형
    3. 매개변수들의 자료형
  • 비교: 함수 정의(definition)은 실제로 함수를 구현해 놓은 것.
    • 함수 정의는 그 자체로 함수 선언이기도 하다.(당연)
함수 선언과 정의를 분리
#include <stdio.h>

void foo(void) /* 함수 선언, 전방선언 */

int main(void)
{
  foo();
  getchar();
  return 0;
}

void foo(void) /* 함수 정의 */
{
  printf("foo called");
}

함수 사용 전에 그 함수를 선언한다. 보통 함수 선언은 파일의 제일 위에 선언한다.

함수 선언과 정의가 하나
#include <stdio.h>

void foo(void) /* 함수 정의 */
{
  printf("foo called");
}

int main(void)
{
  foo();
  getchar();
  return 0;
}



전방선언의 작동 원리
  • 컴파일러가 함수 이름과 반환형, 매개변수를 알 수 있음.
  • 컴파일 다음 단계인 링크(link) 단계에서 실제 코드 위치 찾아서 그 구멍에 넣어주겠다는 것.

foo 함수 구현부가 0x01234면 그 곳에 컴파일러가 인식하고 넣어줌.

그럼 함수선언은 int를 반환하면 선언 안해도 되나?
  • C89/90에선 맞는 얘기 . 그래도 함수 선언은 언제나 하자

  • C99부터는 int 가정을 하지 않기 때문

    • 그러나 어떤 컴파일러는 경고만 주고 컴파일 허용은 할 수도 있음.
    • 모든 컴파일러가 그렇단 보장이 없으므로 반드시 선언할 것.



함수 매개변수 평가순서, 피 연산자 평가 순서

int num1 =128
int num2 = 256;

printf(""%d %d", add(num1, num2), subtract(num1, num2))

  • 표준에 따르면, 함수 매개변수 평가순서는 명시되어 있지 않다.(unspeicified)
  • 즉, 컴파일러에 따라 평가 순서가 달라질 수 있다.
  • printf()가 실행 되기 전 , add, subtract호출은 보장

  • 그러나 누가 먼저 실행되는 지는 컴파일러따라 다르다.(컴파일러 맘)
명시되지 않은 피연산자 평가 순서
if(find_next () + spawn()==2)
{
  ~~
}
  • find_next가 먼저 호출되는 보장이 없다.
    • 명시 되지 않음.
  • 한 줄에 있는 함수 호출 순서에 의존해 코드 작성하지 말것
    • 해법은 두 함수를 두줄에 따로 호출하는 것.



기본적으로 한 줄에서 동일한 변수를 여러번 바꾸면 위험하다

이런 코드들도 많이 쓰는데

int main(void)
{
  int num =10;
  num = ++num + num++;
  printf(num);
}

이 경우도 한 줄인가? 어떤 컴파일러는 한줄로 볼 수도 있으므로 이런 경우도 별로 좋은건 아님

한 표현식에서 같은 변수를 여러번 바꾸지 말 것
  • 앞서 봤듯 + 연산자는 피연산자의 평가순서를 강제하지 않음.
  • = 연산자도 마찬가지.
  • 이럴떄 한 줄에서 같은 변수를 여러 번 수정시 정의되지 않은 결과가 나온다.
i = ++i + i++; /* 어떤 일이 정의 될지 모른다.*/
i = i++ + 1; /* 어떤 일이 정의 될지 모른다.*/
array[i] = i++; /* 어떤 일이 정의 될지 모른다.*/

위 셋 다 어떤 일이 정의 될지 모른다.

  • 뭐든 간 가독성도 안 좋은 코드니 안 쓰는게 좋다

    • 좋은 습관도 아니고 동작 정의 된 거도 확인하는게 귀찮은 일이다.

연산자 우선순위와 평가 순서
  • 연산자 우선순위와 평가순서는 서로 아무런 연관이 없음

int result = add(num1, num2) + subtract(num1, num2) *divide(num1, num2);


if(++i ++j && ++k)

++i ||(++j && ++k) 여기서 ++i가 참이면 뒤에 괄호가 먼저든 뭐든 앞이 참이므로(왼쪽 피연산자) 오른쪽 피연산자를 검사할 필요도 없고 이걸 short circuit 평가라고 한다.

  • && 과 는 평가 순서를 강제하는 연산자다.



범위(scope)

블록 범위와 변수 선언 위치
컴파일 오류 나는 코드

int main(void)
{
  int num1 = 10;
  print(num1);

  int num2 = 100;  /* error */
  int result = num1 + num2;  /* error */

  printf(result);
}
컴파일 되는 코드

int main(void)
{
  int num1 = 10;
  print(num1);
  {
    int num2 = 100;  /* error */
    int result = num1 + num2;  /* error */
  }

  /* num2, result 접근 못함 */
  printf(result);
}


  • 함수 중간에 블록을 열고 변수 선언 가능
  • 함수 시작지점에서 모든 변수 선언시 실수할 여지 있음
    • 정확히 어디서 사용하는 변수인지 파악 불가
    • 중간에 값 바뀔 수도
  • 블록 이용해서 함수 중간에 선언하는 것도 방법
    • 이래나 저래나 만족스럽지 않다.

파일범위
  • 어떤 블록이나 매개변수 목록에 안 속하고 파일 안에 있느느 거
#include <stdio.h>

static int s_num = 1024;  /* 여기 */

int add(int op1, int op2);

int main(void)
{
  s_num = add(10,30);
  return 0 ;
}



파일 범위에 있는 변수의 메모리 위치

  • 파일 범위에 있는 변수

    • 다른 소스코드 파일에서 링크 가능
    • 프로그램 실행 동안 공간 차지
    • 즉, 스택 메모리에 들어가는 게 아님.
    • 이들은 데이터 섹션에 들어감.
  • 이게 전역변수

20221009_135154

  • 데이터라는 메모리 따로 있고 운영체제는 같은 메모리.

  • 데이터 섹션 부분은 전역변수 들어감. 작성하는 코드 부분은 코드섹션이 있음.

  • 힙은 나중에 배움. 함수에서 쓰는 건 이런 스택 메모리에 들어감.

  • 메모리 주소가 증가한다는 의미는 , 메모리 주소가 메모리 위치니까 0부터 큰 숫자 이런식으로 증가. 작은 수 부터 큰 숫자. 우리가 생각하기엔 보통 작은 숫자가 위, 큰 숫자가 아래라고 생각할 수 있지만, 컴퓨터 구조에서 메모리 보여줄 떄는 위 처럼 보여주는 경우가 많다.(특히 스택을 얘기할 때는)

알아야 할 건 전역변수는 함수 메모리에 속한 게 아니다.



함수 범위

  • 유일한 예 : label
  • goto 같은 데서 쓰는 것.
  • 함수 안에서 선언된 레이블은 함수 어디서라도 접근 가능.
    • 다른 범위들은 위에서 선언된거만으로 접근 가능
  • goto는 별로 안좋다는데..

20221009_143149



함수 선언 범위

  • 함수 선언의 매개변수 목록에 있는 건 그 목록 안에서 접근 가능.

  • 많이 쓸 일은 없음

  • 다음과 같은 예는 괜찮음.

void do_something(double value, char array[10*sizeof(value)]);

->

void do_something( double value, /* 함수 선언 범위 / char array[10 * sizeof(value)] / value는 첫 번째 매개변수 */ )



const 키워드

  • 기본적으로 모든 변수에 const를 붙이자.
  • 정말 값 변경이 필요한 변수에만 const를 생략하자
  • 원칙적으로 말하면 언어의 기본 동작이 바뀌어야 함
    • 아무것도 안 붙이면 const
    • 굳이 프로그래머가 바뀌는 걸 원하면 앞에 뭔가 붙이기
    • Rust가 이런거 잘한다.



goto 문

goto <label_name>;
...
<label_name>:
  • C는 위에서 아래로 순차적으로 코드를 실행함.
  • goto를 쓰면 이 순서를 다 어기고 다음에 실행할 코드를 맘대로 지정 가능.
반복문은 결국 goto를 사용한다.
  • 어셈블리어는 반복문이 없다.
  • 원래 어셈블리어로 쓰다 C로 넘어와서 초기엔 goto를 많이 썼다.
  • 당연히 안전한건 일반 반복문을 쓰는 것.
  • 근데 goto를 악마라고 아예 쓰지 말라는 것도 문제였긴하다.(물론 요새는 안쓰는게 정석)



goto는 정말 쓰면 안되나?

  • 쓰면 안됨.
  • 엄청난 길이의 코드가 만들어지기 때문
  • 그래도 유용하게 쓰이는 경우도 있기는 하다.

20221009_192404

goto 없이 for문에서 탈출하려면 if문 여러개 써야한다. 근데 goto는 한번에 탈출 가능

20221009_193019

20221009_193601

20221009_193216

goto가 좋지는 않지만 쓰이는 곳들이 있기는 하다.

C가 아니라 C#이든 다른 언어든 수행할 수 있는 경우도 있기는 함.

20221009_193934

goto 대신 함수를 따로 만들면 된다. 근데 꼭 함수 만드는 게 좋은거 아니다.

함수 만드는 순간 거기에 대한 과부화도 걸리고 오버헤드도 있기 떄문. 그래서 함수 안 쓰는게 성능에 있어서 만큼은 좋다.

그럼 일반 프로그래밍 or 유지보수 문제에서는 함수호출 쓰는게 좋긴 한데 결과적으로는 함수호출만으로 해결 못하는 걸 여기서 처리함.

에러 있으면 어디로 점프함. out나감. 만약 A에러 있으면 A했던거 뒤집어라 그리고 나감. A가 되고 B도 되는데 B가 문제생기면 out_b로 가서 B뒤집고 이런식. C로 가면 out_c가서 C 뒤집고

이런식으로 1,2,3,4 연산 있을때 뒤집어갈떄는 goto가 유리함. 이런걸 함수 만들려면 굉장히 힘들다.

당장 MacOS의 소스코드에서도 goto를 많이 사용한다.

c#은 goto를 지원하기도 한다.

20221009_203456

goto 베스트 프렉티스는

  • goto문은 언제나 전방(아래쪽)으로만 점프
    • 위로 점프시 스파게티 코드 됨
  • 내포된 루프에서 빠져나올 땐 자유로이 쓴다.

  • 한 함수 안에 여러개 조건문이 공통된 코드 실행해야 할 떄도 안 써도 됨.
    • 예: 함수 마지막에 성공/오류 조건 처리
  • 근데 회사에서 쓰지 말라면 안쓰면 됨.



스택 메모리

  • C는 어떻게 new를 안 만들고 도나?

  • new넣은 애는 값형이 아니라 참조형이라 했다.
  • 그래서 복사도 안된다고했다.



C는 값 형으로도 배열을 만들 수 있다.

  • 사실 모든 자료형은 참조형으로도 , 값형으로도 만들 수 있다.
  • 이걸 알려면 스택 메모리를 알아야 한다.



스택 메모리란

  • 자료 구조인 스택과는 다른 개념
    • 둘다 작동 방법이 동일해 LIFO 스택이란 이름 쓸 뿐
  • 스택은 함수 호출할 떄 사용. 함수에 보면 함수마다 각각 지역변수들이 있는데 그 지역변수를 어딘가에는 저장해 놔야 스택 메모리에서 공간 잡고 여기에 지역변수 넣는다.

  • 각 함수에서 사용하는 지역변수 등을 임시적으로 저장하는 공간
  • 스택 메모리의 크기는 프로그램 빌드 시에 결정됨.
  • 스택 메모리의 위치는 실행시에 결정됨. (실행할 때 마다 달라지므로)
  • 컴파일을 할 떄 컴파일을 하지만 exe파일을 가져다 다른 컴퓨터에서 실행함.

  • 기억해야 할 것은 스택 메모리가 얼마나 크냐 정의하는 것은 프로그램 빌드 시에 정의를 한다.

  • 그 크기 값이 이제 실행파일에 보면 거기에 헤더파일이 있다.(실행파일이 뭔지 설명하는) 거기에 프로퍼티 중 하나로 들어간다. 스택사이즈가 얼마이닞 적혀있고 스택 위치는 실행할 때 마다 달라지기 때문에 실행할 때 결정된다.


스택 메모리는 결국

함수가 호출 될 떄마다 그 함수에서 필요한 공간을 스택에서 떼었다가 그 함수가 반환시 흔들어 지워버리는 개념.

  • 실제 지우지는 않는다.(쓰레기 값으로 놔둠)

load를 제일 먼저 호출 메모리 빌려옴. 아래로 쭉 실행하면서 메모리 빌려옴.

20221009_223403



기본 자료형 변수는 스택 메모리를 차지

  • 여태 모든 기본 자료형 변수를(char, float, int) new 없이 쓸 수 있던 거도 스택 메모리에 할당 됐기 떄문이다.

  • 기본 자료형을 함수 매개변수로 전달시, 스택에 복사본을 만듬 -> 이게 값형.

  • 스택 메모리를 빌리고 반환시마다 언제나 빈 공간 없이 차곡차곡 쌓음.

  • new 로 만든 데이터는 힙(heap) 메모리에 할당됨.

    • 이 경우 메모리에 구멍이 뚫릴수 있음.



스택 메모리에 대해서 간단히 알아보자

20221009_230244

  • 스택이 큰 주소에서 작은 주소로 쌓임.
  • ESP(Extended Stack Pointer)
    • 현재 스택 포인터
  • EBP(Extended Base Pointer)
    • 현재 스택 프레임의 기본(첫) 주소. (입구). 현재 어디까지 차 있는지 보여주는 포인터.
  • 스택 프레임(Stack frame)
    • 각 함가 사용하는 스택 메모리의 범위



스택 메모리 안의 배열, 스택 오버플로우

  • 스택의 크기는 한정적.
  • 타겟 플랫폼 따라 달라짐. 심지어 컴파일 시 프로그래머가 스택 크기 정해줄 수도 있음.
  • clang window에서 아무 설정 없으면 1mb 정도.
스택 오버플로우는 이 스택을 넘어가는 경우에 생긴다
  • 스택의 크기가 1mb일떄 아래 코드 실행하면
int add (const int a, const int b)
{
  char buffer [1024 * 1024];
  int res = a+b;
  return res;
}



너무 큰 데이터는 스택에 넣으면 안된다.
  • 너무 큰 데이터는 스택에 못 넣음

  • 이럴 경우에 쓰는 게 동적 메모리 할당(c나 자바에서의 new)

    • OS에게 메모리 달라고 요청하는 것.



재귀함수를 깊이 쓰면 스택 오버플로우 나는 이유도 같은 이유

  • 함수 한번 호출할 때 마다 그 함수 스택 프레임 만큼 바이트를 더 먹음.

  • 그 함수가 반환하지 않고 계속 다른 함수를 호출하며 스택 올리면 언젠가 1mb를 다 쓴다.



int values[30];
size_t array_size = sizeof(values); //120

sizeof(values) 는 values 배열이 차지하는 총 바이트 수를 반환

  • 그 이유는?
    • 이 배열이 스택에서 몇 바이트 차지하는 지 컴파일 중 알기 때문

꼼수


배열의 요소 개수 구하는 방법

  • 방법1:
    const size_t num_vals = sizeof(values) /sizeof(values[0]);
    
  • 방법2:

함수 밖에서

define array_length(arr)  sizeof(values) /sizeof(values[0]);

매크로 함수 사용

const size_t num_vals2 = array_length(values);



sizeof(매개변수) 와 배열의 총 바이트 수

20221010_004500

sizeof()가 매개변수로 들어온 배열의 총 바이트 수를 반환할 수 있으려면 그 배열의 모든 요소가 스택에 다 복사되어 있어야함



사용하는 스택 크기가 달라야 한다
  • 즉 , 다음 두 함수 호출에서 매개변수 전달에 사용하는 스택 크기가 달라야함.

20221010_005029

왼쪽은 매개변수 20개, 오른쪽은 50개

process()라는 함수는 어셈블리어가 이미 컴파일 된건데, 하나는 20개 불러온 어셈블리어, 하나는 50개

ebp 20, ebp 50인 함수가 있다(같은 함수인데?)

=> 불가능

함수 스택 메모리 사용량은 고정.

  • 함수는 호출자가 누구든 딱 정해진 수와 크기의 매개변수 가 들어온다는 가정으로 동작

  • 함수가 먼저 결정되고 호출자는 그 함수를 호출할 뿐.
  • 즉, 함수는 호출자가 뭐 하는 놈인지 모른다.

  • 함수의 스택 메모리 사용량은 고정이 되어있는거.



sizeof(매개변수)가 4를 반환한 이유는?

20221010_011846

결국 포인터의 주소 크기였던 것(주소 알려주는게 전부)


길이가 명시된 매개변수 배열
void process(int nums[5])
{
  size_t i;
  for (int i = 0; i<5 ; i++){
    nums[i] *= 2;
  }
}

이건 배열 5 길이인걸 넣어준게 아닌가?

-> 프로그래머 편의를 위함. 50이건 50000이건 컴파일은 nums[]와 동일하게 함. 대신 개발자가 보고 쉽게 판단 가능.

  • 위 코드는 복사본이 아니라 원본을 바꿈

    • 이걸 “참조에 의한 호출”이라고 함.(원본을 바꾸기 떄문)
    • 어떤 사람은 “값에 의한 호출” 이라고 함(위치를 복사하는거라)
    • 어떤 사람들은 C는 참조에 의한 호출이 없고 그냥 주소를 전달하는 법을 통해 참조에 의한 호출 을 시뮬레이션 하는 방법.
    • 중요하지 않다. 원본이 바뀐다는 거만 알자.



매개변수의 길이 , 배열요소의 초기값

  • 매개변수는 들어오는 길이 알 방법은 없음.
  • 배열 자체에서 크기를 알아올 수 있는 방법은 있음.
    • 예외: 아까 array_length() 꼼수(매크로)를 쓸 수 있는 경우

    • 즉, 배열의 크기는 따로 기억해둬야 한다.

void process(size_t n, int nums[5])
{
  size_t i;
  for (int i = 0; i<5 ; i++){
    nums[i] *= 2;
  }
}

이런식으로 바꿔야 함.



배열 요소의 초기값

  • C는 배열 요소의 값을 초기화 해주지 않음.

  • 따라서 그 전 메모리 남아있던 값을 그대로 사용
    • 이건 변수도 마찬가지.
    int nums[30]; /* 속에 뭔 값이 들어있는 지 모름 (쓰레기값)*/
    int val; /* 마찬가지 */
    
  • 변수 초기화는 했고 배열 초기화 방법은?

배열의 초기화

20221010_015358



가장 좋은 방법 : 배열의 모든 값을 0으로

  • 배열의 모든 값 0으로 초기화 하는 방법은?

      int nums[10] ={0,}
    
  • 0 뒤에 쉼표를 찍자

  • 이를 통해 초기화 목록 모든 값을 직접 초기화 해주지 지만 쉼표 뒤가 모두 0으로 초기화 됨을 보여줌.

이래서 C는 위험하다.

20221010_020056

버퍼 오버플로가 나면 다른 곳들 까지 0으로 바꿈.

내가 가진 공간이 아닌 다른 애가 가진 공간을 덮어씌우는데 C는 이 버퍼 오버플로우를 체크 안해줘서 만약 이게 나면 전에 덮어쓴 데이터 떄문인걸 모를 수 있다. 이 경우 크래시가 나야하는데.

이런 버그를 메모리 stomp라고 한다.

이건 가장 고치기 힘든 문제(메모리를 밟고 지나간 상황)

이걸 찾아서 고치는 게 프로그래머의 자질.



다차원 배열

  • 2차원 배열 , 3차원 배열

  • 2차원 배열의 경우, C# 에서는 int[,] , C에서는 int[][]

20221010_020740

여기까지가 C 만의 문법 보기 전 까지 기본 문법 및 작동원리는 얼추 알아봄.

위까지 이해하면

  • 다른 언어에서 배웠던것들은 C언어에서 어떻게 작용하는지.
  • C에서 다르게 동작하는 것은 무엇인지.

  • 다음은 내 소스코드가 어떻게 실행코드로 바뀌는지 보자.






© 2021.03. by yacho

Powered by github