C언어 총정리

Jun Hwi Ku·2022년 12월 3일
0

이 강좌는 C언어의 기초 필수 문법을 설명합니다. 초보자가 쉽고 빠르게 이해할 수 있도록 구성되어 있으며, 꼭 지금 알 필요 없는 어려운 개념들은 부록으로 정리하였습니다. 내용이 많이 함축되어 있기 때문에 모든 내용을 정확히 이해하고 넘어가도록 합시다.

무언가 프로그램을 제작하고 싶다면, 처음부터 반복문 챕터까지는 확실히 이해하고 있어야 직접 프로그램을 작성할 수 있습니다. 코드업 이나 백준 과 같은 사이트를 이용하여 연습 문제들을 풀어보세요. 비트연산을 제외한 코드업의 모든 문제정도는 풀 줄 알아야 합니다!

Visual Studio 시작하기


Visual Studio

위 링크에서 Visual Studio Community 버전을 받아줍니다. 업데이트가 됨에 따라 이름에 2019, 2022 등 년도가 다를 수 있지만 최신버전을 받아주면 됩니다.

만약 컴퓨터 사양이 많이 낮다면, Visual Studio Code를 이용할 수도 있습니다.

실행 후, [C++를 사용한 데스크톱 개발] 을 체크해준 후에 설치를 진행합니다.

Visual Studio을 실행시킨 후, [새 프로젝트 만들기(N)] 를 클릭한 후에 언어를 C++으로 설정하고 [빈 프로젝트]를 고른 뒤에 프로젝트 이름과 경로는 아무렇게나 설정해준 뒤에 "만들기" 를 누릅니다

그 후에 "솔루션 탐색기" 라고 아래와 같은 구조로 되어있는 창에서

프로젝트명
├── 외부 종속성
├── 리소스 파일
├── 소스 파일     <---
└── 헤더 파일

[소스 파일] 을 선택한 후, 우클릭->추가->새 항목 을 선택합니다. (또는 단축키 [Ctrl+Shift+A])

그리고 [C++ 파일(.cpp)] 을 선택한 후(자동으로 선택되어있습니다) 원하는 이름으로 설정해 주되 확장자를 .cpp에서 .c로 변경합니다. .cpp는 c plus plus로 C++을 의미합니다.

그러면 방금 만든 파일이 열리면서 텍스트를 적을 수 있게 됩니다. 이제 이곳에서 첫 프로그램을 작성해보도록 하겠습니다.

기초 사항


#include <stdio.h>

int main() {

  printf("Hello, World!");

  return 0;
}

위 코드를 작성하고 [Ctrl+F5] 단축키를 누르면 콘솔 창이 열리며

Hello, World!

라는 문장이 출력됩니다. 이제 각 코드가 무엇을 의미하는지 한번 알아보도록 합시다.

  • #include 는 다른 코딩파일을 가져오겠다는 뜻이고, <stdio.h> 는 "Standard Input/Output library" 로써 C언어를 사용하기 위해 필수로 가져와야 하는 파일입니다.

  • int main() { ... }함수 라고 하는데, 이는 나중에 설명되기 때문에 그 전까지는 꼭 적어줘야 한다고만 알아두시면 됩니다. 모든 프로그램은 이 main 함수에서 시작합니다. return 0; 또한 함수에서 배우는 내용입니다.

그리고 각 문장을 보시면 끝에 ;(세미콜론) 이 보이는데, 이는 프로그래밍에서 "문장의 끝"을 나타냅니다. 몇몇 언어마다 차이는 있지만, 보통 ;를 적어주지 않으면 컴퓨터가 그건 문장이 끝나지 않았다라고 판단하게 됩니다. 즉, 코드에 아무리 공백을 넣거나 줄바꿈을 하더라도 ;에서만 코드 하나가 끝나게 됩니다. 또한 중괄호 {} 도 비슷한 역할을 하는데, 여러 줄의 코드를 포함하는 문장에 사용됩니다. 프로그래밍 익숙해지기 위해서 이 문서 외에도 여러 예제들을 직접 적어보고 테스트해보며 익혀봅시다.

주석Comment

주석이란, 코드를 읽는 사람을 위해 적어주는 것으로 실제 프로그램에는 아무런 영향이 없는 글자입니다. //는 한 줄만, /* */는 여러 줄을 주석처리하기 위해 사용합니다.

#include <stdio.h>

int main() {

  // printf("실행 안됨");

  /*
    printf("이것도");
    printf("실행");
    printf("안됨");
  */

  return 0;
}

변수Variable


프로그래밍에서는 여러가지 값들을 기억해둘 필요가 있습니다. 게임을 예로 들자면 체력이나 공격력, 이동속도 같은 능력치가 있을수도 있고 아니면 단순히 계산에 사용될 값이 될 수도 있습니다.

이 값을 기억하기 위해 사용되는 것이 변수입니다. 변수는 특정한 자료형Data Type의 값을 저장하는 공간의 이름으로, 컴퓨터의 메모리(RAM)에 저장됩니다. 자료형에 대해서는 나중에 설명됩니다.

#include <stdio.h>

int main() {

  int a;
  a = 5;

  return 0;
}

위 코드는 a라는 변수를 작성하고, 5라는 정수를 대입assign하는 코드입니다.

<자료형> <변수이름> 와 같이 작성하는 것을 "변수를 선언declare한다" 라고 하고, 위 코드에서는 int a; 로 되어있는 부분입니다.

이 때, 변수 이름에는 몇 가지 규칙이 존재하는데

  1. 변수 이름은 대소문자를 구별한다
  • aA는 서로 다른 변수입니다.
  1. 변수 이름은 숫자로 시작할 수 없다
  • 1coin 은 불가능하지만, coin1 은 가능합니다.
  1. 변수 이름에는 _ 를 제외한 특수문자가 들어갈 수 없다
  • test@gmail.com, test value, hashtag# 등은 전부 불가하고 _ 만 가능합니다.

그리고 변수 이름으로 한글을 쓰는게 가능하긴 하지만, 무조건 알파벳을 사용해 주세요.

변수를 선언하면 컴퓨터는 메모리(RAM)에서 비어있는 공간을 찾아서 자료형의 크기만큼의 공간을 확보하고, 그 공간의 주소를 변수가 가리키게 합니다. 자세한 내용은 포인터에서 배우게 됩니다.

자료형Data Type


프로그래밍에서는 값을 저장하기 위한 다양한 자료형(타입)이 존재하는데, 크게 3가지로 정수형, 실수형, 문자형 가 존재합니다.

종류 명칭 크기 값 범위
정수형 short 2 -32768 ~ 32767
int 4 -2147483648 ~ 2147483647
long 4 -2147483648 ~ 2147483647
long long 8 -9,223,372,036,854,775,808 ~ 9,223,372,036,854,775,807
문자형 char 1 -128 ~ 127
부동소수점형 float 4 1.2E-38 ~ 3.4E38
double 8 2.2E-308 ~ 1.8E308

1비트는 0과 1만을 가지고 있고 8비트가 모여 1바이트가 되는데, 컴퓨터는 이진 데이터만 저장할 수 있기 때문에 1바이트의 경우 2의 8승의 숫자를, 4바이트의 경우 2의 32승의 숫자까지 표현할 수 있습니다.

정수형

  • 수학의 정수와 같이 소수점이 없는 숫자만 저장됩니다. 만약 소수점이 있는 숫자를 넣으면 소수 부분을 버리고 정수 부분만 저장합니다 (예: 1.5 -> 1)

문자형

  • 문자를 저장하는데 사용됩니다. 하지만 컴퓨터는 문자 자체를 저장할 수 없기 때문에 각 문자에 대응하는 숫자를 정해두고, 문자 대신 정해둔 숫자가 저장이 되며, 문자로 출력할 때는 그 숫자에 대응하는 문자가 출력됩니다. 이를 문자 인코딩이라고 하는데, C언어에서는 아스키(ASCII) 인코딩 방식이 사용됩니다.

부동소수점형

  • 실수형이라고도 불리며 소수점을 저장할 수 있는 자료형입니다. 부동 소수점(floating point) 은 말 그대로 소수점이 부동(float) 할 수 있음을 의미합니다. 정수형이랑 바이트 크기는 같은데 표현할 수 있는 값은 훨씬 큰 대신, 내부적인 구조로 인해 값에 오차가 생기게 됩니다. float의 경우 소수점 약 8자리, double의 경우 소수점 약 16자리까지 정확하게 표현할 수 있습니다.

여기서 변수가 아니라 3, 5 처럼 직접 적어준 값을 상수 혹은 리터럴literal 이라고 합니다. 이런 리터럴에도 타입이 존재하며, 2.0 과 같이 소수점을 붙이면 이는 double형이 되고, float형 리터럴은 뒤에 f를 붙여 2.0f 와 같이 나타냅니다. 소수 값이 없다면 int형입니다.

문자 타입의 경우 A라는 문자를 표현할 때는 'A' 와 같이 작은 따옴표로 감싸고, 문자가 여러개인 문자열을 사용할 때는 "Hello, World" 처럼 큰 따옴표로 감쌉니다.

일반적으로 정수형이라면 int, 실수형이라면 double을 사용합니다. int가 정수의 기본형이기도 하며 20억이라는 범위는 일반적으로 충분하며, 실수형의 경우 정밀도 문제때문에 double을 주로 사용합니다.

부록: 2진수 계산과 오버플로우

자료형이 가질 수 있는 최대 숫자보다 더 큰 값을 입력하면 오버플로우가 발생해서 해당 자료형이 가질 수 있는 최소 값으로, 반대로 최소 숫자보다 작은 값을 입력하면 언더플로우가 발생해서 해당 자료형이 가질 수 있는 최대 값으로 이동됩니다.

#include <stdio.h>

int main() {
    short over = 32768;
    short under = -32769;

    printf("Overflow : %d\n", over);
    printf("Underflow : %d\n", under);

    printf("More Overflow : %d\n", over + 32768);

    return 0;
}

하지만 단순히 최대/최소가 되는 것이 아니라, 넘은 만큼 더 이동하게 됩니다. 이를 이해하기 위해서는 메모리 구조와 2진수를 알아야 하는데 1바이트를 기준으로 설명해드리겠습니다.

0000 0000

1바이트는 위와 같이 8비트로 이루어져 있습니다. 만약 0111 1111 이라면 이는 2^7인 128이 되는거죠. 그런데 2진수로는 음수를 표현할 방법이 없어서 맨 왼쪽에있는 비트가 0이라면 양수, 1이라면 음수로 판단합니다.

그래서 char의 최대값인 127에 해당하는 0111 1111 에 1을 더하면 1000 0000 이 되는데, 이는 음수의 최솟값인 -128에 해당됩니다. 언더플로우도 같은 원리이나 2진수의 뺄셈은 2의 보수를 사용하기 때문에 여기서는 설명하지 않겠습니다.

입/출력하기


변수를 입력받을때는 scanf_s() 라는 함수를 쓰고, 변수를 출력할때는 printf() 라는 함수를 씁니다. 제일 처음에 Hello, World! 라는 메세지를 출력한 함수도 printf()인데, 어떻게 쓰는지 제대로 알아봅시다.

#include <stdio.h>

int main() {

    int a, b;

    printf("숫자 2개를 입력하세요: ");
    scanf_s("%d%d", &a, &b);

    printf("덧셈 결과는: %d", a + b);

    return 0;
}

printf()에 변수나 값을 출력하는 경우나 scanf_s()로 입력받을 경우에는 안에 %d, %f와같은 형식 지정자 들이 들어가는데, 받으려는 자료형과 똑같은 형식지정자를 적어줘야 합니다. 자주 사용되는 형식 지정자들은 다음과 같습니다.

%d : long long을 제외한 정수형
%f : float형으로 읽습니다
%lf : double형으로 읽습니다
%c : char형으로 읽습니다
%s : 문자열 (char의 배열)

그래서 만약 int형 변수 3개에 값을 입력받고 출력한다면 다음과 같이 하면 됩니다.

#include <stdio.h>

int main() {

    int a, b, c;

    scanf_s("%d%d%d", &a, &b, &c);

    printf("a: %d, b: %d, c: %d", a, b, c);

    return 0;
}

%d가 3개기 때문에 3개의 값을 입력받고, 순서대로 a, b, c에 넣어줍니다. 이 때, scanf_s()는 공백' ', 탭키'\t', 줄바꿈'\n' 을 입력받았을때 하나를 입력받았다고 처리합니다. 여기서 scanf_s에 변수를 적어줄 때 변수의 이름 앞에 &을 적어주는데, 이는 변수의 주소 를 가져오라는 의미입니다. a라고만 적으면 이는 a의 값을 읽어오라는 뜻인데, 우리는 변수 a의 값이 저장된 공간, 즉 a의 주소에 값을 넣어줘야하기 때문에 &을 써야합니다.

위의 printf() 코드를 자세히 설명하자면, "a: %d, b: %d, c: %d" 라고 적어줬을 때 여기서 "a: , b: , c: "라고 적어준 부분은 그대로 출력되고, 형식지정자 %d가 적혀있는 자리에 오른쪽에 ,로 분리해준 값들이 순서대로 대입됩니다. 그래서 첫번쩨 %d에는 a, 두번째 %d에는 b, 세번째 %d에는 c의 값이 들어가게 됩니다.

입출력을 확실히 이해하고 넘어가는 것이 좋기 때문에 스스로 여러 예제를 만들거나 찾아보아 실습을 다양하게 해봅시다.

이스케이프 문자

이스케이프 문자는 줄바꿈이나 문법상 출력할 수 없는 문자를 출력하기 위해 만들어졌습니다. \(역슬래쉬) 의 경우, 엔터 바로 위에 있는 키이며 글꼴에 따라 원화표시가 나올수도 있지만 그래도 정상적으로 작동합니다.

\n : 줄 바꾸기 (엔터키와 동일)
\t : 수평탭 문자 (탭키와 동일)
\\ : 백슬래쉬(\)
\' : 작은 따옴표
\" : 큰 따옴표(쌍 따옴표)
%% : 퍼센트(%)

예시로 다음과 같이 사용할 수 있습니다.

printf("안녕하세요.\n시즈닝입니다.");

/* 결과:

안녕하세요.
시즈닝입니다.
*/

문자 타입 입력받기

printf()와 다르게 scanf_s()에는 _s가 붙어있는데, 이는 safe의 약자입니다. 문자 타입을 받을 때 버퍼 오버플로우 가 발생할 수 있기 때문이므로 _s 가 붙은 함수들은 최대 몇 글자까지 입력 가능한지를 뒤에 숫자로 적어줘야합니다.

예시 코드:

char a, b;
scanf_s("%c%c", &a, 1, &b, 1);
printf("%c %c", a, b);

char 변수 하나는 한 글자만 받을 수 있기 때문에 1이라고 적으면 됩니다. 여러 글자를 받는 문자열은 이후에 배우는 내용입니다.

그런데 위의 코드를 실행하면 b에 입력이 되지 않는 경우가 있습니다. AB 같이 입력하면 잘 나오는데, A B 와 같이 중간에 입력하면 나오지 않죠. 그 이유는 A라는 문자를 입력받은 후에 입력의 종료를 나타내는 문자도 같이 입력받기 때문입니다 (공백' ', 탭키'\t', 줄바꿈'\n'). 숫자 타입의 경우 이런 문자들을 자동으로 무시하기 때문에 괜찮지만, char타입에는 저장이 되기 때문에 그래서 이 문자들을 무시해준다는 의미로 형식지정자 앞에 띄어쓰기를 해줄 수 있습니다.

char a, b;
scanf_s(" %c %c", &a, 1, &b, 1);
printf("%c %c", a, b);

연산자(Operator)


대입 연산자(Assignment Operator)

위에 변수에서 배웠던 = 는 수학에서 사용되던 좌변과 우변이 같다 와 완전히 다른 의미입니다. 할당 연산자라고도 불리는 이것은, 오른쪽에 있는 값을 왼쪽의 변수에 넣어준다는 뜻입니다.

대입 연산자는 우선순위가 제일 낮고, 대입된 값을 반환 하기 때문에 다음과 같은 코드가 가능합니다.

a = b = c = d = e = 5

e = 5가 계산되면 그 결과값은 5가 되고, 그 값을 다시 d에 넣고... 를 반복하면 모든 변수의 값이 5가 됩니다.

산술 연산자(Arithmetic Operator)

  • + : 두 값을 더합니다.
  • - : 두 값을 뺍니다.
  • * : 두 값을 곱합니다.
  • / : 두 값을 나눕니다.
  • % : 두 값을 나눈 나머지를 구합니다.

키보드에는 나눗셈 기호가 없기때문에 /를 나눗셈 연산자로 사용하고, 곱셈을 x라고 적기에는 문자 X와 비슷하니 *가 사용되었습니다.

특이하게 나머지 연산자가 존재하는데, 이게 프로그래밍에서는 매우 유용하게 쓰입니다. 실수형은 나머지를 구하는 방법이 다르기 때문에 정수형에만 지원이 되죠.

변수는 사용되기 전에 무조건 초기화되어야 합니다. 대입 연산자로 값을 넣어주지 않았다면 그 변수는 아무 값도 지니지 않기 때문에 사용하려 하면 오류가 발생합니다.

#include <stdio.h>
int main() {
  int a, b;
  a = 5;
  b = 2;
  printf("a + b 는 : %d \n", a + b);  // 7
  printf("a - b 는 : %d \n", a - b);  // 3
  printf("a * b 는 : %d \n", a * b);  // 10
  printf("a / b 는 : %d \n", a / b);  // 2
  printf("a %% b 는 : %d \n", a % b); // 1
  return 0;
}

위의 코드를 보면 a / b의 결과가 2.5가 아니라 2인 것을 볼 수 있는데, 이는 자료형과 관련이 있습니다. a와 b는 모두 int형이기 때문에 그 결과값도 int형이 되어 소수점이 버려지게 됩니다. 그 외에도, %d 로 출력했기 때문에 정수형으로 나온다는 이유도 있습니다.

int a = 5;
double b = 2;

printf("a + b 는 : %lf \n", a + b);  // 7
printf("a - b 는 : %lf \n", a - b);  // 3
printf("a * b 는 : %lf \n", a * b);  // 10
printf("a / b 는 : %lf \n", a / b);  // 2.5

그런데 a는 int형이고 b는 float형인데 제대로 계산이 됩니다. 이 이유는 암시적 형변환이 있기 때문입니다.

형변환Casting

프로그래밍에서는 "형번환" 이라는 개념이 존재합니다. 여기에는 암시적 형변환명시적 형변환이 존재하는데, 암시적 형변환은 자동으로 타입을 바꿔주는 것입니다. 일반적으로 범위가 더 작은 타입에서 범위가 더 큰 타입으로는 자동으로 형변환이 됩니다. 그래서 int와 double과 연산할때는 int가 double로, float와 double과 연산할때는 float가 double로 형변환이 되죠.

하지만 int / int 처럼 같은 타입끼리 연산을 할 때 형변환이 필요하다면 명시적 형변환을 해주면 됩니다. 간단하게 값 앞에 (<자료형>) 을 적어주면 해당 값의 타입이 임시로 바뀌게 됩니다.

int a = 5, b = 2;

printf("결과: %f", (double)a / b); // a를 명시적 형변환하였고, b는 암묵적으로 int에서 double로 변환됩니다.

복합 대입 연산자

만약 어떤 변수에 값을 계속 누적하고싶으면 어떻게 해야할까요? 다음 코드를 확인해봅시다.

int a = 0;
a + 3;
a + 5;

a에는 3과 5가 더해져 8이 들어가있을까요? 아닙니다. a는 그대로 0이죠. a에 대입 해준 값은 0뿐이기 때문입니다.

그래서 값을 누적하기 위해서는 다음과 같이 해야합니다.

int a = 0;
a = a + 3;
a = a + 5;

프로그래밍에서는 이런 연산이 자주 사용되기 때문에 a = a + 3 를 축약하여 a += 3 와 같이 사용할 수 있으며 이를 복합 대입 연산자라고 합니다. -=, *=, /=, %= 등 거의 모든 연산자에 대해 복합 대입 연산자가 존재합니다.


다음 연산자들은 항이 하나인 단항 연산자입니다.

부호 연산자(Sign Operator)

-, + 연산자들이 이에 해당합니다. +a -a 와 같이 사용되며, +는 의미가 없고 -는 부호를 반대로 바꿔줍니다.

증감 연산자(Increment and Decrement Operator)

++, -- 연산자들이 이에 해당합니다. a++ a-- ++a --a 와 같이 사용되며, ++는 변수에 1을 더해주고 --는 1을 빼줍니다. 이 때, 연산자가 앞에 붙는 것과 뒤에 붙는것은 차이가 있습니다.

앞에 붙는 전위형의 경우 우선순위가 제일 높아 다른 모든 연산보다 먼저 실행되고, 뒤에 붙는 후위형의 경우 우선순위가 제일 낮아 다른 모든 연산이 끝난 후에 실행됩니다.

int a = 1;

printf("%d\n", ++a);   // 2
printf("%d\n", a++);   // 2
printf("%d\n", a);     // 3
a++;
++a;
printf("%d\n", a);     // 5

연산을 헷갈리게 만들기 때문에 일반적으로 잘 사용되지 않으며, 전위형이나 후위형으로 통일하고 다른 연산자와 같이 사용하지 않는 것이 좋습니다.


마지막으로, 연산자는 수학의 연산순서를 그대로 따라갑니다. 그래서 곱셈과 나눗셈, 나머지를 먼저 계산하고 덧셈과 뺄셈은 나중에 계산하죠. 그래서 괄호 연산자 또한 존재합니다.

(a + b) / 3 * (c - d)

조건문


프로그래밍에서 특정한 경우에 특정한 결과가 일어나게 하려면 어떻게 해야할까요? 예를들어, 비밀번호에 숫자 최소 하나, 특수문자 하나, 대소문자 하나씩 필요하다는 것을 어떻게 확인할까요?

바로 이런 "만약에 ... 하면" 이라는 것을 코드로 가능하게 해주는 게 조건문입니다. 즉, 특정 조건에 특정 코드를 실행해주는 것이죠.

if, else, else if

if문의 사용법은 다음과 같습니다.

조건문 예제1

int a;
scanf_s("%d", &a);
if (a == 0) {
  printf("입력받은 숫자는 0입니다.");
} else if (a < 0) {
  printf("입력받은 숫자는 음수입니다.");
} else {
  printf("입력받은 숫자는 양수입니다.");
}

위 프로그램은 정수를 하나 입력받아, 0인지 음수인지 양수인지를 구분해 주는 프로그램입니다. 이제 if문의 문법과 함께 코드를 살펴보도록 하겠습니다.

if문은 if (<조건식>) { <실행될 코드> } 과 같이 적습니다. 이 때 조건식에 0이 들어간다면 거짓False를 의미하여 코드가 실행되지 않고, 0이 아닌 숫자가 들어가면 참True를 의미하여 코드가 실행됩니다. a == 99 과 같은 조건식의 결과값은 참이면 1, 거짓이면 0이기 때문에 a가 99면 코드가 실행되는 것이죠.

else문은 if문 뒤에 사용되며, 이전의 if문의 조건이 만족하지 않을 경우에 실행됩니다. 그리고 else 바로 뒤에 if를 붙여 "위의 조건이 아니면서, 추가 조건이 있는 경우" 를 봐줄 수 있습니다. 그래서 위의 else if (a < 0) 이라는 코드는 "a가 0이 아니고 0보다 작을 경우" 를 의미하고, else 코드는 "a가 0이 아니고 0보다 작지 않은 경우" 를 의미합니다.

if에서 else if, else는 선택사항이고 else는 단 하나만 사용할 수 있지만 else if는 무한개가 올 수 있습니다.

if문에서는 코드가 한 줄만 있다면 다음과 같이 중괄를 생략할 수 있습니다.

if (a == 0)
  printf("입력받은 숫자는 0입니다.");
else if (a < 0)
  printf("입력받은 숫자는 음수입니다.");
else
  printf("입력받은 숫자는 양수입니다.");

또한, if문은 다음과 같이 무한히 중첩이 가능합니다.

if (age < 0) {
  printf("나이가 잘못 입력되었습니다.");
} else {
  if (gender == 0)
    printf("여성이시군요.");
  else if (gender == 1)
    printf("남성이시군요.");
}

혹시라도 if (a == 0); 과 같이 if문 바로 다음에 세미콜론을 붙이지 않도록 조심하세요. 이렇게 하면 조건이 맞더라도 아무 코드도 실행하지 않고 끝나게 됩니다. 익숙하지 않다면 처음에는 중괄호를 사용하는 것을 추천합니다.

조건식

  • 관계 연산자
A == B   // A와 B가 같은가?
A != B   // A와 B가 다른가?
A > B    // A가 B보다 큰가? (B를 포함X)
A >= B   // A가 B 이상인가? (B를 포함함)
A < B    // A가 B보다 작은가? (B를 포함X)
A <= B   // A가 B 이하인가? (B를 포함함)

위는 조건 연산자들입니다. 프로그래밍에서 = 는 대입 연산자로 사용되기 때문에, 두 값이 서로 같은지 조건을 확인하고 싶다면 == 을 사용해야 합니다.

조건식의 결과는 조건이 맞을경우 1, 조건이 틀릴경우 0이 나옵니다. 아까 말씀드렸다시피 if문같은 조건문은 0은 거짓, 0이 아닌 모든 숫자는 참이기 때문에 조건이 만족할 때 코드가 실행되는 것이죠.

  • 논리 연산자
!<조건식>             // NOT 연산
<조건식> || <조건식>  // OR 연산
<조건식> && <조건식>  // AND 연산

논리 연산자는 조건식 자체에 사용되는 연산자입니다. 조건식 앞에 ! 를 붙이면 조건식의 결과를 반전합니다. !(a == b) 의 경우 "a와 b가 같지 않을 경우" 가 됩니다. 이를 NOT 연산이라고 합니다.

조건식 사이에 || (쉬프트와 함께 \) 를 넣으면 두 조건식 중 하나라도 참일 경우 참을 반환하고, 아니면 거짓을 반환합니다. a == b || a == 0 의 경우 "a가 b랑 같거나 a가 0일 경우" 가 됩니다. 이를 OR 연산이라고 합니다.

조건식 사이에 && 를 넣으면 두 조건식이 모두 참이어야 참을 반환하고, 아니면 거짓을 반환합니다. 1 <= a && a <= 10 의 경우 "a가 1 이상이면서 10 이하일 경우" 가 됩니다. 이를 AND 연산이라고 합니다.

  • 조건 연산자

삼항 연산자라고도 불리며, <조건식> ? <코드> : <코드> 와 같이 사용됩니다. 밑의 예제로 알아봅시다.

int a;
scanf_s("%d", &a);

// 절댓값
a = a < 0 ? -a : a;

printf("%d", a);

위의 코드는 a가 음수일 경우 -를 붙여서 양수로, 양수일 경우 a를 그대로 써서 절댓값을 구해줍니다. 조건 연산자는 <조건식> ? <참일경우 코드> : <거짓일경우 코드>; 처럼 쓰여서 if ... else 문을 하나로 만든 것과 같습니다. 또한, 2개 이상 중첩할 수도 있지만 가독성이 나빠 거의 사용되지 않습니다.

switch

switch문은 특정한 상황의 코드에 가독성을 높여주며, if문과 치환이 가능합니다.

int menu;
scanf_s("%d", &menu);

switch(menu){
  case 1:
    printf("1번 메뉴를 선택했습니다!");
    break;
  case 2:
    printf("2번 메뉴를 선택했습니다!");
    break;
  case 3:
    printf("3번 메뉴를 선택했습니다!");
    break;
  default:
    printf("그런 메뉴 없는데요?");
}

switch(<값>) 와 같이 비교에 사용될 값을 적어주며, case <값>: 에서는 switch의 값과 case 뒤의 값이 같은 경우 진입하여 코드들을 실행합니다. 그런데 한번 진입하면 다른 case를 무시하고 전부 실행해버리기 때문에 break문으로 switch문을 나가주어야 합니다.

default: 의 경우 조건 없이 무조건 들어가게 됩니다.

반복문


반복문에도 2가지 종류가 있는데, 반복문은 특정 조건에 특정 코드를 반복해서 실행하기 위해 만들어진 문법입니다.

int a, b;
scanf_s("%d%d", &a, &b);
printf("a + b의 값은 = %d\n", a + b);

scanf_s("%d%d", &a, &b);
printf("a + b의 값은 = %d\n", a + b);

scanf_s("%d%d", &a, &b);
printf("a + b의 값은 = %d\n", a + b);

a와 b를 입력받아서, a + b의 결과를 출력하고 싶은데, 이걸 계속 처리하려면 위와 같이 복사해서 사용해야 합니다. 하지만 이건 엄청 비효율적이고, 사용자는 이걸 3번만 해보고 싶을수도, 100번도 넘게 사용할 수도 있기 때문에 한계가 있죠.

while, do ... while

while문은 주로 "특정 조건이 만족하는 동안" 반복하기 위해 사용됩니다. while (<조건식>) { <실행될 코드> } 와 같이 사용되고, 위의 코드를 사용자가 0을 2개 입력할 때까지 계속 실행하도록 변경해 보겠습니다.

int a, b;
scanf_s("%d%d", &a, &b);

while (a != 0 && b != 0) {
  printf("a + b의 값은 = %d\n", a + b);
  scanf_s("%d%d", &a, &b);
}

이제 실행해 보면 0 0 을 입력하기 전까지 계속 계산이 되는 것을 볼 수 있습니다. 조건문에 "a가 0이 아니면서 b가 0이 아닐경우" 라고 적어 주었기 때문에, while문은 해당 조건이 만족하는 동안 계속 코드를 반복합니다.

여기서 while문은 조건을 먼저 확인하고 코드를 실행하므로 a와 b가 초기화되어 있어야 하기 때문에 반복문 이전에 입력을 미리 받아주었습니다. 물론 int a = -1, b = -1; 처럼 0이 아닌 값을 미리 넣어줄 수도 있지만, 반복문 안에서 전부 처리되게 하고 싶다면 어떻게 해야할까요?

int a, b;

do {
  scanf_s("%d%d", &a, &b);
  printf("a + b의 값은 = %d\n", a + b);
} while (a != 0 && b != 0);

do ... while 문은 조건을 확인하기 전에 먼저 실행(do) 하고, 그 후에 조건을 확인합니다. 그래서 일단 한번은 실행하고 조건을 봐줄 수 있다는 장점이 있죠. 그런데 실행해 보면 나갈 때 0+0의 결과값을 출력하고 나가게 된다는 것을 알 수 있습니다. 이는 나중에 break 문에 대해서 배운 후에 해결해 봅시다.

for

for문은 주로 "특정 횟수만큼" 반복하기 위해 사용됩니다. 그래서 while보다는 for문이 많이 사용되지만, 상호간의 변환이 간단합니다. for문의 구조는 for (<초기식>; <조건식>; <증감식>) { <실행될 코드> } 입니다. for문은 초기식 -> 조건식 -> (조건이 참이면)코드실행 -> 증감식 -> 조건식 -> 코드... 의 순서대로 반복되고, 초기식/조건식/증감식은 선택사항이어서 비워둘 수 있습니다. 이렇게 이론으로만 들으면 어렵기때문에 실제 코드로 알아봅시다.

int a;
scanf_s("%d", &a);

for (int i = 1; i <= 9; i++) {
  printf("%d * %d = %d\n", a, i, a * i);
}

위의 코드는 입력한 숫자의 구구단을 출력하는 for문입니다. int i = 1 이 먼저 실행되어서 i가 1이 되고, 조건을 확인하고, 조건이 참이므로 a * 1 을 출력하게 됩니다. 그 후에 i에 1을 더하는 증감식 i++ 을 만나고, 다시 조건을 확인하고... 그렇게 i가 1부터 9까지 반복되면서 구구단을 출력하는거죠.

위에서 for문 부분만 아래처럼 while문으로 바꿀수도 있습니다.

int i = 0;
while (i <= 9) {
  printf("%d * %d = %d\n", a, i, a * i);
  i++;
}

이제 for문을 다시 보면 while문에 변수 초기화와 변수 증감식 부분이 추가된 것 뿐이란 걸 아실 수 있습니다.

다시 더 간단한 for문 예제를 보여드리면, "Hello, World" 를 5번 출력하는 코드는 다음과 같습니다.

for (int i = 0; i < 5; i++)
  printf("Hello, World\n");

i가 5보다 작은 동안, 즉 0에서 4까지 증가하며 코드를 실행하고, i가 4일때 실행이 끝나면 증감식에 의해 1이 더해져 i가 5가 되고, i < 5 라는 조건을 만족하지 못해 반복문을 빠져나가게 됩니다.

또, 반복문도 한줄만 있을 경우 중괄호를 생락할 수 있습니다.

break, continue

break문을 만나면 현재 실행중인 반복문 을 빠져나가게 됩니다.

int a, b;
while (1) {
  scanf_s("%d%d", &a, &b);
  if (a == 0 && b == 0) 
    break;
  printf("a + b의 값은 = %d\n", a + b);
}

위의 코드는 무한 반복문 이라는 기법인데, 조건에 0 이 아닌 숫자를 넣어 무한번 반복시키고 if와 break를 활용해서 특정 조건에 빠져나가도록 하는 것이죠. 출력을 하기 전에 조건으로 확인을 해서 빠져나가기 때문에 0 0을 입력해도 0이 출력되지 않고 빠져나옵니다.

continue문은 만나면 바로 다음 반복으로 건너뜁니다. while문의 경우 바로 조건식으로 가고, for문의 경우 증감식으로 갑니다.

for (int i = 1; i <= 10; i++) {
  if (i % 2 == 0)
    continue;
  printf("%d ", i);
}

위의 코드는 i를 2로 나눈 나머지가 0인 경우, 즉 i가 짝수인 경우에 건너뛰도록 되어서 홀수만 출력되게 됩니다. 조건이 만족하면 바로 i++가 있는 증감식으로 넘어가기 때문이죠.

중첩 반복문

중첩 반복문이란 말그대로 반복문 안에 반복문을 넣는것을 의미합니다. if문 중첩과 똑같지만 반복을 하기 때문에 처음에 이해하기 난해할 수 있기에, 다양한 문제들을 풀어보는 것을 추천합니다.

예제 코드:

for (int i = 0; i < 5; i++) {
  for (int j = 0; j < 5; j++) {
    printf("*");
  }
  printf("\n");
}

실행 결과:

*****
*****
*****
*****
*****

바깥쪽 반복문이 반복할 때마다 안쪽 코드의 실행을 끝내기 때문에, 바깥쪽 반복마다 안쪽 반복문이 5번 반복해 총 25번 반복하게 됩니다.

안쪽 반복문이 5번 반복해서 별을 5개 출력하고 나면, 바깥쪽 반복문에서 줄바꿈 문자를 출력해서 위와 같은 별 사각형이 출력되게 됩니다.

배열(Array)


배열이란? 단순하게 여러개의 변수를 한꺼번에 선언해주는 기능입니다.

int arr[5];

arr[0] = 10;
arr[1] = 20;
arr[2] = 30;
arr[3] = 40;
arr[4] = 50;

printf("%d %d %d %d %d", arr[0], arr[1], arr[2], arr[3], arr[4]);

만약 위의 코드를 배열없이 만드려면 어떻게 했어야할까요? int a0, a1, a2, a3, a4 와 같이 일일이 써줘야 했을겁니다. 그리고 배열의 경우 []안의 숫자만 바꿔주면 크기가 바로 바뀌는데 변수로 선언하면 그런것도 불가능하죠.

배열의 선언 방법은 변수와 완전히 똑같지만, 변수 뒤에 [<크기>] 를 적어줌으로써 해당 숫자크기의, 해당 타입의 배열을 만들 수 있습니다. 또, 배열의 원소(값)를 쓰고 싶다면 <배열이름>[<번호>] 와 같은 방법으로 쓸 수 있죠. 여기서 중요한 점은, 배열은 1번째가 아니라 0번째부터 시작한다는 점입니다.

또한, 위의 코드를 반복문을 사용하도록, 그리고 배열의 원소에 입력을 받도록 만들어 더 간단하고 유용하게 만들 수 있습니다.

int arr[5];

for(int i = 0; i < 5; i++)
  scanf_s("%d", &arr[i]);

for(int i = 0; i < 5; i++)
  printf("%d ", arr[i]);

위에서 보이듯이 배열은 보통 반복문과 같이 쓰입니다. 0번부터 원하는 번호까지 간단하게 접근할 수 있기 때문이죠.

만약 배열을 변수처럼 선언과 동시에 초기화해주고 싶다면(배열도 초기화를 해야합니다) 다음과 같이 해줄 수 있습니다.

int arr1[5] = {1, 2, 3, 4, 5};
int arr2[] = {1, 2, 3, 4, 5};
int arr3[5] = { 9 };

위와 같이 중괄호를 이용해 초기화하면 0번부터 순서대로 해당 값으로 초기화가 됩니다. 또한, arr2 처럼 크기를 적어주지 않으면 뒤에 적어준 값의 개수만큼 초기화가 됩니다.

그럼 arr3은 어떻게 되는걸까요? 첫번째 원소의 값이 9로 바뀝니다. 그런데, 중괄호로 초기화를 하면 나머지 모든 원소들은 자동으로 기본값인 0으로 초기화가 됩니다. 그래서 arr3의 원소들은 { 9, 0, 0, 0, 0 } 이 되는거죠.

배열을 쓰시면서 주의하실 점은, 배열의 범위를 벗어나는(음수번호 포함)값을 번호로 넘겨주면, 배열의 범위를 벗어나므로 오류가 발생하게 됩니다.

N차원 배열

2차원 배열은 우리 주변에서 간단하게 볼 수 있습니다. 표의 형태를 가지고 있기 때문이죠. 변수가 점이면 배열은 선, 2차원 배열은 면, 3차원 배열은 상자... 이런식으로 기하학적으로 표현할 수도 있습니다. 그런데 2차원 배열이란 정확히 무엇일까요?

2차원 배열을 컴퓨터적으로 살펴보면 배열의 배열 이 됩니다. int형 배열이라는 것은 각 원소가 int형 변수 라는 뜻이죠? 그러면 int형 배열의 배열은 각 원소가 int형 배열 인 것입니다.

int arr[5][10]; 와 같이 선언하면 크기가 10인 int형 배열이 5개 있다 라는 뜻이 됩니다. 왼쪽에 있을수록 차원이 높고, 오른쪽에 있을수록 차원이 낮기 때문이죠. 그러면 0번째 배열의 4번째 원소는 arr[0][4] 와 같이, 대괄호를 2개 써서 접근합니다.

또한 차원은 int arr[2][5][10][10]; 처럼 얼마든지 늘릴 수 있게 됩니다. 하지만 4차원 이상은 코드를 이해하기 버거워지기 때문에 대부분 2차원까지만, 가끔 3차원을 씁니다.

int arr_d2[2][3] = {{1, 2, 3}, {4, 5, 6}}; // {1, 2, 3, 4, 5, 6} 도 됩니다
int arr_d1[6] = {1, 2, 3, 4, 5, 6};

1차원이든 2차원이든 메모리 상에서는 값이 순차적으로 들어가 있기 때문에, 단지 접근 방식에 차이를 둘 뿐입니다.

마지막으로 3차원 배열의 반복문 접근예제입니다.

int arr[3][2][5], cnt = 0;

for(int i = 0; i < 3; i++){
  for(int j = 0; j < 2; j++){
    for(int k = 0; k < 5; k++){
      arr[i][j][k] = ++cnt;
    }
  }
}

for(int i = 0; i < 3; i++){
  for(int j = 0; j < 2; j++){
    for(int k = 0; k < 5; k++){
      printf("%d ", arr[i][j][k]);
    }
    printf("\n");
  }
  printf("\n");
}

함수


함수는 코드의 집합이라고 할 수 있습니다. <반환 자료형> <함수 이름>(<매개변수 목록>){ <실행될 코드들> } 의 형태로 선언합니다. 함수에는 매개변수parameter라는 입력값과 실행될 코드, 그리고 반환return할 결과값이 존재합니다.

함수를 사용하는 것을 함수를 "호출call" 한다 라고 하고, 함수를 호출할 때 매개변수에 넘겨주는 값을 인수arguments라고 합니다. 하지만 보통 매개변수와 인수라는 용어는 구분없이 사용되어 매개변수를 인수라고 하기도 하고 인수를 매개변수라고 하기도 합니다.

// start부터 end의 합을 구해서 반환(return)하는 함수
int getSum(int start, int end){
  int sum = 0;
  for (int i = start; i <= end; i++)
    sum += i;
  return sum;
}

int main() {
  int result = getSum(1, 10);
  printf("1부터 10까지의 합: %d", result); // result에 안넣고 바로 GetSum(1, 10)을 넣어도 됩니다.

  return 0;
}

getSum()을 보시면 int형 값 2개를 매개변수로 받는데, 각각의 변수 이름이 startend 이고, 함수의 자료형은 int인 GetSum 이라는 함수를 만들었다고 할 수 있습니다. 안쪽에서는 start부터 end까지의 합계를 구해서, return 문으로 계산된 값을 반환합니다.

여기서 return 이란, 함수를 호출한 곳으로 값을 전달하고 함수를 종료하는 역할을 합니다. 그래서 main() 함수의 result 변수에 GetSum의 결과값이 전달된 것이죠.

함수의 자료형은 반환되는 값의 타입을 의미하며. 만약 int형 함수에서 return 5.5; 를 하면 5 가 반환됩니다.

int giveSomething(){
  printf("5를 드릴게요!");
  return 5.5;
  printf("10은 안되나요?");
  return 10;
}

위의 함수는 5를 반환하고 바로 종료합니다.

지금까지 프로그램을 작성하며 사용한 main함수는 특별한 함수인데, 프로그램을 실행하면 운영체제가 main이라는 이름의 함수를 호출하며 프로그램이 시작하게 됩니다.

또한, 지금까지 적어주었던 return 0; 는 운영체제에 값을 반환하는 것을 의미하며 0은 프로그램이 정상적으로 종료되었음을 의미합니다. 최근에는 적어주지 않아도 컴파일러가 자동으로 추가해주기 때문에 필수는 아니게 되었습니다.

void 타입

함수에는 void라는 타입이 존재합니다. 이는 반환값이 존재하지 않는다는 뜻이고, 함수 조기 종료를 위해 return을 사용할 수는 있지만 return 뒤에 아무 값도 넣을 수 없습니다.

void printDivide(int a, int b){
  if (b == 0) // 0으로 나누면 오류가 발생하므로 조기 종료합니다.
    return;

  printf("결과값: %d", a / b);
}

변수의 범위

변수에는 범위가 존재하며, 중괄호 안에 선언된 변수는 해당 중괄호가 끝나는 시점에 메모리에서 제거됩니다.

int main() {
  int a = 5;
  
  if (a == 5) {
    printf("a는 알고있어요: %d\n", a);
    int b = 3;
  }
  printf("b라는 변수가 어디있죠? %d\n", b);   // 오류!
}

그래서 위의 코드는 b라는 변수를 찾지 못해 오류가 발생합니다.

같은 이유로 이름이 같더라도 범위가 다르면 다른 변수입니다.

int Add(int x, int y) {
  x += 10;
  y -= 5;
  return x + y;
}

int main(){
  int x = 2, y = 5;
  
  int result = Add(x, y);
  printf("%d + %d = %d", x, y, result);
}

Add의 x, y와 main의 x, y 변수는 이름만 같고 범위가 다르기 때문에 서로 다른 변수입니다.

지역변수, 전역변수

그렇게 어느 중괄호 안에 선언된 변수를 지역변수라고 하고, 아무 중괄호 안에도 들어있지 않은 변수를 전역변수라고 합니다. 지역변수는 스택(Stack)이라는 메모리 공간에 상주하고, 전역변수는 힙(Heap)이라는 메모리에 상주합니다. 메모리 구조는 이 문서에서는 자세히 다루지 않겠습니다.

int cnt;   // 전역변수는 자동으로 초기화됩니다

void Counter() {
  cnt++;
}

int main() {
  Counter();
  Counter();
  Counter();
  printf("%d", cnt);   // 3 출력
  return 0;
}

일반적으로 전역 변수는 상수가 아니라면 절대 사용하지 말아야 합니다. 어디에서나 수정될 수 있다는게 편하게 보일 수 있지만, 이 말은 언제 어디서 수정될지를 파악하기 매우 힘들다는 것을 의미하기도 합니다. 단, 알고리즘 풀이 등 간단한 코드에서는 편의성을 위해 사용해도 괜찮습니다.

정적변수

정적 변수는 static 변수 앞에 이라는 키워드를 붙여서 선언하는 것으로 만들 수 있고, 변수를 스택(Stack)이 아닌 스태틱(Static) 메모리 공간에 상주하도록 변경합니다. 스택 공간은 변수의 범위가 끝났을 때 사라지는 반면, 스태틱 공간은 프로그램이 실행될 때 초기화되고 프로그램이 종료될 때 제거됩니다.

void Counter() {
  static int cnt; // int static cnt; 도 가능합니다.
  cnt++;
  printf("%d ", cnt); 
}

int main() {
  Counter();
  Counter();
  Counter();
  return 0;
}

함수 원형(함수 시그니처)

C언어에서는 호출하려는 함수가 호출하는 코드보다 위에 선언되어있어야 합니다. 만약 호출하는 코드보다 밑에 있다면 컴파일러가 아직 해당 함수의 정보를 읽기 전에 호출하려고 한 것이므로, 오류가 발생하게 됩니다.

만약 호출하는 코드보다 더 밑에 정의하려는 경우는 다음과 같이 함수 원형의 정의를 먼저 해줌으로써 가능합니다.

int Add(int, int);

int main() {
  int sum = Add(10, 15);

  return 0;
}

int Add(int a, int b) {
  return a + b;
}

일반적으로 함수는 위와 같이 정의하고 사용합니다. 함수 원형에 필요한 정보는 함수 타입, 이름, 매개변수 입니다.

포인터(Pointer)


변수는 메모리에 저장됩니다. 그리고 컴퓨터도 저장된 위치를 알아야 하기 때문에 메모리는 1바이트 단위로 공간이 나뉘며 숫자로 주소가 표현됩니다. 예를들어 int a; 라고 하면 int는 4바이트 크기가 필요하기 때문에 4바이트의 빈 공간을 찾습니다. 100번째에서 빈공간을 찾았다면, a에는 100이라는 주소가 지정되고 값을 읽어올 때는 100번째부터 4칸, 즉 100~103 까지의 값을 읽어와서 사용합니다. 여기서 중요한 점은, a라는 변수는 주소 100에 값을 저장한다는 것입니다.

포인터는 이런 주소를 저장하고 다룰 수 있게 해주는 타입입니다.

int a = 5;
int *p;
p = &a;

포인터 변수를 선언하려면, 변수 앞에 * 을 붙이면 됩니다. 위의 코드는 p라는 이름의 포인터 변수를 만들고, 그 p에 a의 주소를 담는 코드입니다. 주소를 담는 것을 주소를 가리킨다point 고 부르기도 하고, 참조reference 한다고도 합니다. & 연산자를 이용해 변수의 주소를 얻을 수 있으며, scanf_s() 에서도 사용되었습니다.

헷갈릴만한 점은, int *p, a; 라고 하면 p만 포인터고, a는 포인터가 아닙니다. a도 포인터로 하고 싶다면 int *p, *a; 라고 해야합니다.

포인터에는 간접 참조 연산자 * 라는 특별한 연산자가 존재하는데, 이는 포인터가 가리키고 있는 값을 사용함을 의미합니다.

int a = 5;
int *p = &a;

printf("%d\n", *p); // 5
*p = 10;
printf("%d\n", a); // 10

위 코드를 보면, *p 를 사용하여 자신이 담고 있는 주소의 값을 읽거나, 주소의 값을 수정해서 a 자체의 값을 바꿔주었습니다.

그런데 이렇게 코드를 작성할 거라면, a = 10; 이라고 하면 되지 굳이 포인터를 사용할 이유가 없습니다. 그래서 포인터가 사용되는 이유들을 이제 알아보도록 하겠습니다.

배열과 포인터

배열은 포인터로 구현됩니다. int s1, s2, s3, s4, s5; 와 같이 변수를 5개 선언하면, 이 변수는 각기 다른 메모리 공간에 4바이트씩 할당하게 됩니다. 하지만 int s[5]; 처럼 배열로 선언하면 int 5개의 공간인 20바이트를 한번에 할당합니다. 그러면 이 배열의 첫 번째 원소의 주소만 알면, 이 주소부터 8바이트 뒤, 16바이트 뒤가 배열의 3번째, 5번째 값이 될 수 있을 겁니다. 그래서 배열을 선언하면 첫번째 원소의 주소가 배열의 이름에 저장됩니다.

// 주소는 0x00, 0x04, 0x08... 과 같은식으로 일정한 간격이 됩니다
int s[5] = {10, 20, 30, 40, 50};

printf("%d %d", *s, *(s+4)); // 10 50

코드에서 눈여겨 볼 점은, s + 4를 하면 16바이트 뒤에 있는 50이 출력된다는 점입니다. 사실, 포인터는 전부 똑같이 주소를 저장하는데 각 자료형마다 포인터 타입이 따로 존재한다는 게 이상하다고 생각하셨을 수도 있습니다. 포인터 타입이 존재하는 이유는, 포인터에는 정수로 +/- 연산이 가능한데 이 때 int 포인터에 1을 더하면 실제로는 4가 더해지고, char 포인터에는 1이, double 포인터에는 8이 더해집니다. 다음 예제를 확인해보세요.

char *cPtr = (char *)1000;
int *iPtr = (int *)1000; // 일반적으로 주소를 직접 적어줄 일은 없기 때문에 몇몇 컴파일러는 명시적 형변환을 해주지 않으면 오류를 나타냅니다.
double *dPtr = (double *)1000;

printf("%u %u %u\n", cPtr, iPtr, dPtr); // 1000 1000 1000
cPtr++; iPtr++; dPtr++;
printf("%u %u %u\n", cPtr, iPtr, dPtr); // 1001 1004 1008

그래서 개발자가 굳이 모든 자료형의 바이트 크기를 알 필요 없이 "포인터 + N" 연산을 통해 "현재 자료형의 크기만큼 N칸 건너뛴" 주소를 쉽게 구할 수 있으며, 이는 배열에서만 사용됩니다.

그런데 배열을 다룰때 *(s+4) 와 같이 작성하면 불편하고 가독성도 떨어지기 때문에 C언어 개발자들은 다음과 같은 연산자를 만들어냈습니다.

s[4]; // *(s+4) 와 동일합니다

그리고 이게 바로 배열이 0부터 시작하는 이유입니다!

함수와 포인터

함수의 매개변수를 포인터로 설정하면 입력된 변수 자체의 값을 변경할 수 있습니다.

void Swap(int a, int b) {
  int tmp = a;
  a = b;
  b = tmp;
}
/*
a = b;
b = a;
와 같이 하면, a = b에서 a값이 이미 b값으로 변경되기 때문에 위와 같이 a의 값을 임시 변수에 저장해서 변경합니다.
*/

int main(){
  int x = 5, y = 3;
  
  Swap(x, y);
  printf("%d %d", x, y);

  return 0;
}

위 코드에서 두 변수의 값을 바꾸는 Swap(int, int) 함수를 만들었습니다. 하지만 실행해보면 두 변수의 값이 서로 바뀌지 않았음을 알 수 있습니다. Swap(x, y) 라고 적는 것은 x와 y의 값을 읽어오는 것이기에, Swap(5, 3) 이라고 적는 것과 다를 바가 없습니다. 그래서 Swap 함수에서는 a와 b에 x와 y의 값이 복사될 뿐, x이나 y변수에 대해서 알 방도는 없습니다.

이렇게 값을 전달하여 호출하는 것을 값에 의한 호출(Call by Value) 이라고 합니다. 만약 넘겨준 x, y값 자체가 바뀌는 함수를 만들고 싶다면 다음과 같이 포인터를 사용해야 합니다.

void Swap(int* a, int* b) {
  int tmp = *a;
  *a = *b;
  *b = tmp;
}

int main(){
  int x = 5, y = 3;
  
  Swap(&x, &y);
  printf("%d %d", x, y);

  return 0;
}

이렇게 주소를 전달하여 x, y를 참조할 수 있게 호출하는 것을 참조에 의한 호출(Call by Reference)라고 합니다.

이제 scanf_s() 를 호출할 때 왜 변수에 &를 붙여 주었는지도 이해가 되셨을 거라 생각합니다.

추가적으로, 배열은 포인터이기 때문에 배열을 함수에 전달해주고 싶을때는 포인터 매개변수를 사용하면 됩니다.

상수 변수, 상수 포인터

상수 변수는 값을 바꾸지 못하는 변수를 의미합니다. const 키워드를 자료형 앞이나 뒤에 붙여주어 선언하면 됩니다. 이는 주로 실수로 바꾸고 싶지 않은 값, 절대 바뀌지 않을 값들을 선언할 때 사용됩니다. 예를들면 수학의 파이값이 있을 수 있습니다.

const int PI = 3.141592

const 변수는 선언 후에 값을 바꾸지 못하기 때문에, 무조건 선언과 동시에 초기화해주어야 합니다.

상수 포인터의 경우, 가리키는 주소의 값 을 바꾸지 못한다는 기능을 가지고 있습니다.

int main() {
  int a = 0;
  const int* p = &a; // int const* p = &a 도 가능합니다

  *p = 5; // 에러

  return 0;
}

그래서 p가 가리킬 주소를 변경할 수는 있지만, 가리키고 있는 주소의 값을 바꾸지는 못합니다.

그런데 상수 포인터에서 만약 *의 뒤에 const를 붙이면, 이는 가리키는 주소를 바꾸지 못한다는 다른 의미가 됩니다.

int main() {
  int a = 0, b = 5;
  int* const p = &a;
  *p = 5;
  p = &b; // 에러

  return 0;
}

그래서 가리키고 있는 주소의 값을 바꿀수는 있지만, 다른 대상을 가리키게는 불가능하죠.

const int* const a; 와 같이 혼용해서 사용할 수도 있습니다.

부록: 재귀 함수

재귀recursive 함수란, 함수 내부에서 자기 자신을 호출하는 것을 의미합니다. 어떻게 보면 무한 반복문이라고도 볼 수 있죠. 예를들어 N! (팩토리얼, 5! == 5*4*3*2*1) 은 재귀함수로 다음과 같이 나타낼 수 있습니다.

int fact(int n) {
  if (n == 1) return 1;
  return n * fact(n - 1);
}

int main() {
  printf("%d", fact(5));   // 120
}

처음에 n에 5가 들어갔고, n은 1이 아니니까 5 * fact(4) 가 return됩니다. 그런데 return하기전에 함수를 먼저 실행해서 fact(4)가 호출되면 이 함수는 4 * fact(3) 을 반환합니다. 그 다음은 3 * fact(2), 2 * fact(1)... fact(1)은 1을 반환하고 더이상 fact()를 호출하지 않아서 이제 거꾸로 빠져나옵니다. 그러면 2 * fact(1)에서 fact(1)의 반환값이 1이었으니 2 * 1 이 되고, 이게 반환되어서 3 * fact(2) 에서 fact(2)의 반환값이 2 * 1 이었으니 3 * 2 * 1이 되고, 그다음은 4 * 3 * 2 * 1, 그다음은 5 * 4 * 3 * 2 * 1 이 되면 마지막으로 main함수에 5 * 4 * 3 * 2 * 1 의 값인 120 을 전달하고 끝이 납니다.

부록: 함수 포인터

함수에도 포인터가 있습니다. 어? 그러면 함수 코드가 메모리(RAM)에 있어야 하지 않나요? 네. 함수뿐만 아니라 코드 전체가 메모리에 존재합니다. 함수 포인터의 기능은, 매개변수와 반환타입이 같은 아무 함수의 주소를 담을 수 있습니다.

int add(int a, int b) {
  return a + b;
}
int sub(int a, int b) {
  return a - b;
}
int mul(int a, int b) {
  return a * b;
}
int div(int a, int b) {
  return a / b;
}

int main() {
  int (*op)(int, int);
  
  op = add;
  printf("%d\n", op(10, 5));
  op = sub;
  printf("%d\n", op(10, 5));
  op = mul;
  printf("%d\n", op(10, 5));
  op = div;
  printf("%d\n", op(10, 5));
}

함수 포인터는 다음과 같이 정의합니다.

<반환 타입> (*<포인터 이름>)(<매개변수>);

처음에 언급했듯이, 해당 함수 포인터와 똑같은 구조인 아무 함수를 넣어줄 수 있습니다. 위의 코드는 같은 구조의 사칙 연산자들을 만들어서, 함수 포인터가 가리키게 만들어서 호출하는 코드입니다.

함수 포인터의 사용법은 익혔지만, 사용 이유에 대해서는 아직 애매할 수 있습니다. 그래서 두 번째 예제로 값을 순서대로 정렬하는, 버블 정렬을 구현한 sort() 함수를 만들었습니다.

버블 정렬이란, 5 2 4 3 1 과 같은 값이 배열에 들어있고 오름차순으로 정렬하려한다면, 이 배열을 반복문으로 순서대로 보면서 자기가 가진 숫자와 자기 다음숫자끼리 비교해서 만약 뒤의 숫자가 더 작다면 서로 위치를 교환해하는 것입니다.

그렇게 전체를 한번 반복하게 되면 5가 제일 크기때문에 다른 숫자들이 5와 자리를 계속 바꿔주어서 2 4 3 1 5 와 같이 되며, 5의 정렬은 완료되었음을 알 수 있습니다. 전체를 정렬하기 위해서는 이 과정을 최소 4번 반복해야 하기 때문에 위의 작업을 길이-1 만큼 반복한 것이 버블 정렬입니다.

#include <stdio.h>

void sort(int* arr, int size, int (*op)(int, int)) {
    for (int i = 0; i < size - 1; i++) {
        for (int j = 0; j < size - 1; j++) {
            if (op(arr[j], arr[j + 1])) {
                int tmp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = tmp;
            }
        }
    }
}

int bigger(int a, int b) {
    return a > b;
}

int lesser(int a, int b) {
    return a < b;
}

int main() {
    
    int arr[10] = { 10, 5, 2, 4, 7, 8, 1, 3, 9, 6 };

    for (int i = 0; i < 10; i++)
        printf("%d ", arr[i]);
    printf("\n");

    sort(arr, 10, bigger);

    for (int i = 0; i < 10; i++)
        printf("%d ", arr[i]); // 내림차순 정렬
    printf("\n");

    sort(arr, 10, lesser);

    for (int i = 0; i < 10; i++)
        printf("%d ", arr[i]); // 오름차순 정렬
    printf("\n");

}

만약 이 코드가 이해가 되셨다면, 함수 포인터가 엄청난 녀석이란걸 아실겁니다. sort()는 배열과 배열의 길이, 그리고 함수 포인터를 매개변수로 받는데 이 함수 포인터는 정렬 내부에서 대소비교를 할 때 쓰이게 됩니다. 그래서 현재 숫자보다 뒤의 숫자가 더 작을때 참(True)를 반환하는 lesser, 그 반대인 bigger를 만들어서 넘겨주면 넘겨준 함수에 따라 정렬순서가 바뀌게 됩니다.

함수 포인터는 이후 객체지향에서 대리자(delegate)라고도 불리는 문법으로 발전하게 됩니다.

문자열string


문자열이란, 간단하게 문자의 나열이라는 뜻입니다. char 타입은 문자 하나를 저장하는 자료형인데, 만약 "Hello, World" 와 같은 문자열을 저장하려면 어떻게 해야할까요? 정답은 [배열을 사용한다] 입니다. char str[50] = "Hello, World"; 처럼 할 수 있죠.

또, 다시 알려드리자면 C언어에서 작은따옴표(' ')는 문자를 의미하고, 큰따옴표(" ")는 문자열을 의미합니다.

널null 문자

char str[50] = "ABC" 라는 코드에서 컴퓨터가 "ABC" 라는 문자열의 길이를 알아낼 수 없기 때문에, C언어에서는 마지막 문자 뒤에 아스키 코드로 0에 해당하는 '\0' 이라는, NULL이라고 불리는 문자를 추가합니다. 길이를 알고 싶다면, 문자열의 0번째부터 계속 반복하다 문자열의 i번째가 '\0' 이라면 그때 i의 값이 문자열의 길이인 것이죠.

char str[] = "ABC"; // 문자열을 넣어줄 때는 배열의 크기를 적지 않아도 되는데, 이 경우 넣어준 문자열의 길이만큼 자동으로 설정됩니다.
int i;

for(i = 0; str[i] != NULL; i++) // != '\0', != 0, 또는 왼쪽처럼 NULL이라고 적어도 됩니다. NULL은 기호 상수중 하나이며, 숫자 0과 동일합니다.
  printf("%c", str[i]);

printf("\n문자열의 길이: %d", i);

위 코드처럼 "ABC" 자체를 넣어주면, 큰따옴표는 끝에 NULL문자를 자동으로 삽입하기 때문에 str배열은 char str[4] = {'A', 'B', 'C', '\0'}; 을 해준 것과 똑같아집니다. 만약 "ABC" 를 넣어주지 않고 하나씩 일일이 {'A', 'B', 'C'} 을 넣어줬다면 뒤에 NULL문자가 삽입되지 않는다는 점을 유의해주세요. 또, 이런 이유로 문자열 배열에는 NULL문자가 들어갈 공간이 필요해 문자열의 길이보다 한 칸 크게 선언해야 합니다.

문자열 입출력

문자열을 입력받거나 출력할 때는 "%s" 를 사용하면 됩니다.

char str[50]; // 49글자까지 입력받습니다.
scanf_s("%s", str, 50); // 50 대신 sizeof(str) 라고 해도 됩니다. 이는 배열이 차지하는 바이트 크기를 반환해서, 50이 됩니다.

printf("%s", str);

scanf_s()를 처음 설명드릴때, 문자를 입력받을 때는 몇 글자까지 받을 수 있는지를 뒤에 적어준다고 했었습니다. 이는 단순히 선언한 배열의 크기로 정해주면 됩니다. 또, %s로 입력받을때는 &를 사용하지 않는다는 것을 알 수 있는데, 배열과 포인터에서 배웠듯 배열의 이름은 포인터여서 안에 배열의 주소가 담겨있기 때문입니다.

그런데 Hello World 와 같이 입력에 띄어쓰기를 포함하면 Hello 만 출력되는 것을 볼 수 있습니다. 왜냐면 scanf_s는 **탭키'\t', 공백' ', 줄바꿈'\n' 문자에서 입력을 멈춰버리기 때문인데요. 띄어쓰기가 포함된 문장을 입력받고 싶다면 gets_s() 를 사용해야합니다.

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

int main() {
  char str[50];
  gets_s(str, 50);

  printf("%s", str);
}

gets_s()도 마찬가지로 _s가 붙었기에 몇 글자까지 받는지를 적어줍니다. gets_s() 는 <string.h> 라는 라이브러리 안에 들어있어 #include 해주어야 합니다. <string.h> 에는 이 외에도 다양한 함수들이 존재하기 때문에 모두의 코드 를 참고해주세요!

구조체struct


구조체란, 서로 다른 여러 타입을 하나로 묶어 새로운 자료형을 만드는 것입니다.

만약 지금까지의 내용으로 학교에서 학생들의 정보를 저장하는 프로그램을 작성한다고 합시다. 만약 학생이 30명이라면 학생의 정보 저장을 위해 다음과 같이 만들어주어야 했을겁니다.

int student_number[30], age[30];
char name[30][10];

사용할 수는 있지만, 자료들이 서로 분리되어 있어서 접근성과 가독성이 떨어지게 됩니다. 하지만 만약 구조체를 쓰면 어떻게될까요?

struct student {
  int student_number, age;
  char name[10];
};

int main() {
  struct student stu_list[30];

  // ...이하생략
}

이렇게 간단하게 표현될 수 있습니다! 그럼 이제 구조체 변수를 사용하는 방법을 자세히 알아보도록 할까요?

struct point {
  int x, y;
};

int main() {
  struct point p1, p2;
  struct point p3 = {2, 3};
  p1.x = 5;
  p1.y = -10;

  scanf_s("%d %d", &p2.x, &p2.y);
  
  printf("p1: %d, %d\n", p1.x, p1.y);
  printf("p2: %d, %d\n", p2.x, p2.y);
  printf("p3: %d, %d\n", p3.x, p3.y);
}

위에서 볼 수 있듯이 point 구조체 안에 int x, y; 로 int타입의 변수 2개를 선언해 주었는데, 이를 멤버 변수 혹은 필드Field라고 부릅니다. 그럼 이제 point 구조체는 x, y를 멤버(필드) 로 가지는 새로운 자료형이 되는 것이죠.

구조체 변수를 선언할 때는 struct point <변수명> 와 같이 해주었습니다. 구조체 변수의 멤버에 접근할 때는 . 연산자를 사용하는데, 접근 방식만 다를뿐 변수를 사용한다는 점은 똑같습니다. 또, p3 의 경우 {2, 3} 로 선언과 동시에 초기화했습니다. 구조체에서 선언된 변수의 순서대로 값이 자동으로 들어가게 되죠.

typedef

typedef란, 어떤 자료형을 부르는 이름을 추가해주는 키워드입니다.

typedef int number;

int main() {
  number a, b; // int a, b; 와 동일합니다
}

위처럼 그냥 int를 number로도 부를 수 있게 해주는 것이죠. 주로 구조체와 사용해 이름을 축약합니다.

struct point {
  int x, y, z;
};

typedef struct point point;

int main() {
  point v;
  v.x = 5;
  v.y = 10;
  v.z = -5;
}

struct point 타입을 pointtypedef 를 해주면, 앞으로 구조체 변수를 선언할 때마다 앞에 struct를 붙여줄 필요가 없겠죠? 그런데 이것을 더 쉽게 해줄 수 있는 방법이 있습니다.

typedef struct {
  int x, y, z;
} point;

int main() {
  point v;
  v.x = 5;
  v.y = 10;
  v.z = -5;
}

바로 구조체의 선언과 동시에 해당 구조체를 typedef 해주는 것인데요, 자료형의 선언과 동시에 point라는 이름으로 불리도록 해주는 것입니다.

함수에 구조체 쓰기

위에서 설명드렸다시피 구조체는 자료형이기 때문에, 그냥 자료형과 차이점은 없지만 참고 예시를 보여드리겠습니다.

struct point {
  int x, y;
} ;

struct point subtract(struct point a, struct point b) {
  struct point result;
  result.x = a.x - b.x;
  result.y = a.y - b.y;
  return result;
}

int main() {
  struct point p1, p2;
  scanf_s("%d%d", &p1.x, &p1.y);
  scanf_s("%d%d", &p2.x, &p2.y);

  struct point result = subtract(p1, p2);
  printf("뺄셈 결과: (%d, %d)", result.x, result.y);

  return 0;
}

물론 typedef를 사용하여 가독성을 높일 수 있습니다.

typedef struct {
  int x, y;
} point;

point subtract(point a, point b) {
  point result;
  result.x = a.x - b.x;
  result.y = a.y - b.y;
  return result;
}

int main() {
  point p1, p2;
  scanf_s("%d%d", &p1.x, &p1.y);
  scanf_s("%d%d", &p2.x, &p2.y);

  point result = subtract(p1, p2);
  printf("뺄셈 결과: (%d, %d)", result.x, result.y);

  return 0;
}

구조체 포인터

구조체 포인터는 구조체 타입의 포인터입니다. 일반 변수와 별다를 것이 없지만, 한가지 새로운 연산자가 등장하게 되는데요, 위의 struct point 로 예를 들어보면,

struct point val;
struct point *p = val;

(*p).x = 5;
p->y = 10;

* 연산자의 우선순위보다 . 연산자의 우선순위가 더 높기 때문에 위처럼 괄호를 작성해야 하는데, 포인터의 포인터의 포인터... 같이 복잡해지면(*(*(*p1).node).node).node 과 같이 가독성이 현저히 떨어지기 때문에 배열의 [] 과 같은 이치로 C언어는 -> 이라는 새로운 연산자를 도입했습니다. p->x(*p).x 과 같은 의미입니다. 방금 보여드린 끔찍한 코드도 이 연산자를 쓰면 p1->node->node->node 와 같이 보기좋게 바꿀 수 있게 됩니다.

공용체

구조체랑 똑같은 방법으로 사용하는데, 공용체 자료형 변수를 선언하면 공용체 내 변수중 가장 크기가 큰 자료형만큼만 메모리를 할당합니다. 이 부분은 건너뛰어도 좋습니다.

union test {
  int a;   // 4바이트
  char b;  // 1바이트
};

int main() {
  union test t;

  t.a = 10000;
  printf("%d %c\n", t.a, t.b);

  t.b = 'a';
  printf("%d %c\n", t.a, t.b);
}

위의 test가 구조체였다면, int를 위한 공간과 char를 위한 공간을 할당을 해서 총 5바이트가 할당되겠지만, 공용체는 제일 큰 자료형인 int만큼, 그러니까 4바이트만큼의 공간만을 할당합니다. 그래서 a만 바꿨는데 b의 값도 바뀌고, b의 값만 바꿨는데 a의 값도 바뀌게 되는 것이죠.

열거형

상수(리터럴)에 의미를 부여하기 위해 사용됩니다.

enum days { MON, TUE, WED, THU, FRI, SAT, SUN };

int main() {
  char weeks[7][10] = { "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" };

  for (int i = MON; i <= SUN; i++)
        printf("오늘은 %s!\n", weeks[i]);

  return 0;
}

우리가 "0번째 일부터 6번째 일까지" 라고 하는것보다 "월요일부터 일요일까지" 라고 하는 게 가독성이 좋기 때문에, 열거형이라는 것이 추가되었습니다.

위의 열거형은 MON = 0부터 시작해서 1씩 증가한 값이 할당되어 0, 1, 2, 3, 4, 5, 6이 각각 들어갑니다.

만약 enum level { low, medium = 10, high }; 와 같이 선언하면, low에는 0, medium에는 10, high에는 11이 할당됩니다. 바로 이전 열거형 멤버에 1을 더한 값이 저장되는 것이죠.

파일 입출력


변수에 저장된 값들은 RAM에 저장되는데, 이 정보들은 프로그램이 종료되면 전부 사라져버리게 됩니다. 만약 프로그램이 끝나도 정보를 저장해놓고, 프로그램을 켰을 때 저장된 정보를 불러오게 하고 싶다면 하드디스크에 저장해야하는데, 그러기 위해서 사용되는 것이 파일이죠!

int main() {
  FILE* fp;

  fopen_s(&fp, "data.txt", "w");

  if (fp == NULL) {
    perror("파일을 열지 못했습니다.");
    return 1;
  }

  int test;
  scanf_s("%d", &test);
  fprintf(fp, "파일에 정상적으로\n입력 완료되었습니다! %d", test);
}

위의 코드를 실행하고, 아무 숫자나 입력해 보고, 소스코드가 있는 폴더에 들어가보면 (어딨는지 모르시다면 [솔루션 탐색기] 에서 "솔루션 \<솔루션이름>" 를 우클릭하고 [파일 탐색기에서 폴더 열기] 를 클릭해보세요) data라는 txt 파일을 찾을 수 있습니다!

fopen_s(파일포인터, 파일이름+확장자, 모드)
파일을 여는 역할을 합니다. 파일에서 입력받을 때는 파일의 주소를 알아야 하기 때문에 FILE * 타입을 사용해서 입력받습니다. 두번째 매개변수에는 확장자를 포함한 파일 이름을 적고, 세번째 매개변수는 파일을 열 때 선택할 모드입니다.

r : read, 입력할 때 씁니다. 파일이 없으면 오류가 발생합니다.
w : write, 출력할 때 씁니다. 파일이 없을 경우 새로 만들고, 있으면 덮어씌웁니다.
a : append, 출력할 때 씁니다. 파일이 없을 경우 새로 만들고, 있으면 뒤에 이어적습니다.
-b : rb, wb와 같이 사용되며 binary라는 이진 파일로 저장이 됩니다. 여기서는 다루지 않습니다!

파일과 관련된 함수들은 일반적으로 평소에 사용하던 함수 앞에 f를 붙인 버전이며, (fprintf, fscanf_s, fgets_s) 사용법도 거의 똑같습니다. 보통 첫번째 매개변수로 사용할 파일 포인터를 넘겨줍니다.

아래 예제를 진행하기 전에, 코드와 같은 경로에 input.txt 라는 파일을 하나 만들고, 안에 숫자를 원하는 만큼 적어주세요! (띄어쓰기나 줄바꿈, 탭키로 구분)

int main() {
  FILE *fin, *fout;
  fopen_s(&fin, "input.txt", "r");
  fopen_s(&fout, "output.txt", "w");


  if (fin == NULL || fout == NULL) {
    printf("파일을 열지 못했습니다!");
    return 1;
  }
  
  int sum = 0, cnt = 0, max = -2000000000, min = 2000000000, tmp;
  
  while (fscanf_s(fin, "%d", &tmp) != EOF) {
    sum += tmp;
    cnt++;
    if (tmp > max) max = tmp;
    if (tmp < min) min = tmp;
  }
  
  fprintf(fout, "합계는 : %d\n", sum);
  fprintf(fout, "평균은 : %.2lf\n", (double)sum / cnt);
  fprintf(fout, "최대, 최소값은 : %d, %d", max, min);
}

여기서 중요한 점은 fscanf_s(fin, "%d", &tmp) != EOF 부분인데, 파일로 입력을 받으면 파일 끝에 도달했을때 EOF라는 값을 읽어오는데, 이는 End of File 의 약자입니다. 그래서 위의 문장은 파일에서 입력받은 값이 EOF가 아닌 동안 반복을 하는 것이죠. (EOF는 -1라는 값입니다.)

절대경로, 상대경로

fopen_s(..., "input.txt", ...) 를 사용할 때, 중간에는 사실 파일의 경로까지 포함해서 적어줘야 합니다. 하지만 아무 경로도 입력해주지 않아도 코드랑 같은 위치에 파일이 생성되는데, 이는 상대 경로가 사용되어서 그렇습니다. C:/ 처럼 최상위 경로인 드라이브부터 모든 경로를 적는 것은 절대 경로 라고 하며, 예외없이 무조건 똑같은 경로에 파일이 생성됩니다. 상대 경로는 특정 폴더를 기준으로 하는 경로이며, 주로 소스 파일이나 프로젝트의 경로가 기준이 됩니다.

./input.txt : ./는 현재 폴더를 의미하는데, 적어주지 않아도 자동으로 삽입됩니다.
../input.txt : ../는 상위 폴더를 의미합니다. 그러니까 상위 폴더에 있는 input.txt를 의미합니다.

예시를 들면 ./File/Resources/input.txt 처럼 현재 폴더의 File 폴더의 Resources 폴더의 input.txt를 접근할 수 있습니다.

일반적으로 절대 경로보다 상대 경로가 자주 사용됩니다.

동적 메모리


동적 메모리란 말 그대로 프로그램 실행 중에 동적으로 원하는 크기의 배열을 선언하는 겁니다. 왜냐면 C언어에서는 배열의 크기를 무조건 상수로만 정할 수 있기 때문이죠. 이는 실행 전에 필요한 메모리의 크기를 미리 계산해야 하는 스택(Stack) 공간의 특징입니다.

#include <stdio.h>
#include <stdlib.h>

int main() {
  int N;
  scanf_s("%d", &N);

  int* arr = (int*)malloc(sizeof(int) * N));
  for (int i = 0; i < N; i++) {
    scanf_s("%d", arr[i]);
  }

  for (int i = 0; i < N; i++) {
    printf("%d ", arr[i]);
  }

  free(arr);
}

malloc(<정수>) 은 힙(Heap) 공간에서 수동으로 원하는 바이트만큼의 메모리를 할당하는 함수입니다. 예를 들어 malloc(40)하면 40바이트의 공간이 할당되고, 그 공간의 시작 주소가 반환됩니다.

그런데 이 할당된 공간은 int형 기준으로 봤을때는 10개가 들어가고, char형 기준으로 봤을때는 40개가 들어갈 수 있는 공간입니다. 그래서 이 함수는 아무 포인터 타입(자료형)으로나 바뀔 수 있도록 void * 타입을 반환하게 되는데, 이를 명시적 형변환 으로 우리가 써줄 타입으로 변환해주어야 합니다. 위 코드에서는 int형 배열을 만들고 싶기 때문에 int *형 변수를 선언하고, malloc의 반환값을 명시적 형변환 (int *) 을 이용해 타입을 바꿔준 것이죠.

"배열과 포인터" 를 보면 왜 이것만으로 원하는 길이의 배열을 만들 수 있는지 알 수 있는데, 배열을 선언한다는 것은 포인터를 선언하는 것과 같고, 해당 포인터가 배열의 크기만큼 선언된 메모리들의 첫번째 주소를 가지고 있기 때문입니다. 변수랑은 다르게, 배열을 선언하면 배열의 모든 원소들의 주소가 메모리상에 이어져 있기 때문이죠.

sizeof(<자료형>) 는 해당 자료형의 크기를 알려줍니다. 왜냐면 int가 4바이트고, double은 8바이트다라는 메모리 크기를 다 외우고 다닐수도 없고, 특히나 구조체의 경우는 구조체 내부의 모든 멤버변수의 크기의 합을 구해야 하는 등 번거롭기 때문이죠. 그래서 위의 코드에서 sizeof(int) * N 부분은 int 타입이 N개만큼 있는 메모리 크기라고 해석할 수 있습니다.

free(<포인터>) 는 할당된 메모리를 해제해주는 역할을 합니다. 수동으로 할당했기 때문에, 자동으로 해제되지 않기에 꼭 메모리를 해제해주어야 합니다. 그렇지 않으면 쓰이지는 않는데 메모리 공간을 점점 차지하는 메모리 누수 가 발생할 수 있습니다.

2차원 배열을 동적 할당하고싶으면 다음과 같이 더블 포인터를 사용해주면 됩니다. 포인터의 포인터가 있어야 배열의 배열을 사용할 수 있기 때문이죠.

int N, M;
scanf_s("%d%d", &N, &M);

int** arr = (int**)malloc(sizeof(int*) * N);
for (int i = 0; i < N; i++) {
  arr[i] = (int*)malloc(sizeof(int) * M);
}

// 출력 등등

for (int i = 0; i < N; i++) {
  free(arr[i]);
}
free(arr);

일단 배열들의 위치를 기억할 공간을 동적할당하고, 그 배열들을 각각 또 따로 동적할당 해줍니다. 그래서 메모리를 해제할 때도 배열 안의 배열들을 먼저 해제해주고, 마지막으로 배열들의 위치를 가지고 있는 포인터를 해제해 줘야 하죠.

더 알아보기

부록: 매크로 함수

매크로 함수는 함수의 내용을 어느정도 이해하셨다면 아래의 글을 보고 쉽게 이해하실 수 있을거에요.

링크

부록: 모듈화 프로그래밍

지금까지는 거의 소스파일 하나에서만 작업을 했지만, 실제 프로그램에서 이렇게 하면 보기도 불편하고, 다른사람과 협업할 수도 없겠죠? 그래서 아래 글은 소스코드를 분리하는 법을 알려줍니다.

링크

부록: 전처리기

#include 와 같이 #이 붙는 키워드는 전처리기라고 합니다. 가끔 요긴하게 사용되죠.

링크

profile
게임 개발자

0개의 댓글