이 문서에서는 프로그래밍이 처음이신 분들을 위해 프로그래밍 기초를 설명합니다.
언리얼 엔진은 스크립팅 언어로 C++을 사용하므로 C++의 문법을 기반으로 설명합니다.
언리얼 빌드 툴 (UBT)의 백엔드 언어는 C#입니다. 필요에 따라 C#의 문법의 예제가 있을 수 있습니다.언리얼 엔진에서 사용되는 C++ 프로그래밍 표준은 언리얼 엔진 공식 문서와 VLAST 언리얼엔진 스타일 가이드 (Public Ver.) 문서를 참고해주세요.
식당을 예로 들면 프로그래밍은 주방 (컴퓨터)에 주문서 (코드)를 보내기 위해 주문서를 작성하는 것으로 비유해보겠습니다.
컴파일은 여러분이 작성한 코드를 컴퓨터가 바로 이해할 수 있는 언어 (어셈블리 및 기계어)로 번역하는 과정입니다. 여기에서 생성된 결과물은 오브젝트 코드라고 부릅니다. 즉, 손님의 주문을 주방 요리사가 간결하게 알아들을 수 있도록 정리하는 웨이터의 역할을 수행합니다.
링크는 컴파일러가 생성한 오브젝트 코드를 바이너리 파일로 만듭니다. 바이너리 파일은 크게 두 가지 종류로 나눕니다.
Static Link
): 실행 파일 (Windows의 *.exe
, macOS의 *.app
등)Dynamic Link
): 실행 파일의 기능 의존성 모듈 (Windows의 *.dll
, macOS의 *.dylib
등)코드를 작성할 때에는 본인이 작성한 코드를 보기 쉽게 기능 및 작성 의도를 설명하는 것이 좋습니다. 이러한 설명을 주석이라고 부르며, 이 주석은 코드를 컴파일할 때 포함되지 않습니다.
// 주석의 범위는 이 줄에만 유효함
/*
이 범위 내는 모두 주석임
*/
작성한 코드를 설명하기 위한 주석 처리는 일반적으로 다음과 같이 작성합니다.
// 에디터 런타임인지 확인하고 Steam AppID를 특정 디렉터리의 steam_appid.txt에 저장한다.
FFileHelper::SaveStringToFile(TEXT(RAW_APP_ID),
URSTool::IsEditorRuntime()
? *(FPaths::ProjectDir() + TEXT("steam_appid.txt"))
: *(FPaths::RootDir() + TEXT("steam_appid.txt")));
또한 작성한 코드에 문제가 발생했을 때 오류를 막기 위해 코드 자체에 주석을 사용하기도 합니다.
/*
if(const IOnlineSubsystem* Subsystem = IOnlineSubsystem::Get()) {
SessionInterface = Subsystem->GetSessionInterface();
if(SessionInterface.IsValid()) { // Bind Delegates
SessionInterface->OnCreateSessionCompleteDelegates.AddUObject(this, &URedSunGameInstance::OnCreateSessionComplete);
}
}
*/
JSDoc을 사용하여 IDE나 코드 에디터에서 사용자가 코드를 살펴보기 용이하도록 다음과 같이 작성할 수도 있습니다.
/**
* 함수의 설명
* @param {매개변수 타입} "매개변수 이름" 매개 변수 용도 설명
* @returns 리턴 값 용도 설명
* @version 버전
* @see 참고 사이트 링크
* @readonly // const 변수일 때 명시
* @todo 미완성 함수일 때 남은 개발할 것 작성
* @deprecated 더 이상 사용되지 않는 함수
*/
표기법 | 예시 | 설명 |
---|---|---|
카멜 케이스 | camelCase |
첫 글자는 소문자, 이외의 단어의 첫 글자는 대문자로 표기한다. |
파스칼 케이스 | PascalCase |
모든 단어의 첫 글자는 대문자로 표기한다. |
스네이크 케이스 | snake_case |
모든 글자는 소문자로, 띄어쓰기는 언더바로 대체하여 표기한다. |
스크리밍 스네이크 케이스 | SCREAMING_SNAKE_CASE |
스네이크 케이스와 달리 모든 글자를 대문자로 표기한다. 주로 상수나 매크로에 사용한다. |
케밥 케이스 | kebab-case |
모든 글자는 소문자로, 띄어쓰기는 바로 대체하여 표기한다. |
헝가리안 노테이션 | int형일 때 intHungarianNotation |
변수명에 변수의 타입을 접두한다. |
수학의 미지수와 비슷한 개념으로, 자연수가 될 수도 소수가 될 수도 있지만 자연수로 채택된 변수는 소수가 될 수 없습니다. 마찬가지로 반대도 불가능합니다. 이러한 변수의 등록 형식을 변수의 타입이라 부르며, 자연수와 소수를 같이 연산하려면 둘 중 하나의 타입을 바꾸어야 합니다. (형변환)
// 변수 선언 형식은:
// '타입' '변수 이름';
int a = 1; // int (Integer, 정수) 타입의 a라는 이름의 변수 선언. a의 값은 1
float b = 1.2f; // float (실수) 타입의 b라는 이름의 변수 선언. b의 값은 1.2
char c = 'a'; // char (Character, 1바이트 글자) 타입의 c라는 이름의 변수 선언. c의 값은 a
bool d = false; // bool (Boolean, 참과 거짓) 타입의 d라는 이름의 변수 선언. d의 값은 거짓 (false)
// int 값을 float 변수에 할당할 때
float IntToFloat;
IntToFloat = 1; // C++의 경우 암시적 형변환으로 자동으로 1은 1.0f로 형변환됨
IntToFloat = (float)1; // 명시적 형변환으로 1을 1.0f로 형변환함
모든 변수는 전역 변수 (Global Variable)와 지역 변수 (Local Variable)로 나뉩니다. 전역 변수는 코드 내 어디에서든 접근하고 수정이 가능한 변수입니다. 반면 지역 변수는 선언된 함수 내에서만 접근 및 수정이 가능합니다.
#include <cstdio>
int GlobalVar;
void main()
{
int LocalVarInMain;
scanf_s("%d", &GlobalVar);
// 전역 변수는 어디에서든지 사용할 수 있음
}
void TestLocalVar()
{
LocalVarInMain = 0;
// 지역 변수는 선언된 함수 내에서만 액세스할 수 있음
// Cannot resolve symbol LocalVarInMain;
}
같은 타입의 변수 및 클래스 및 구조체로 이루어진 집합입니다. 리스트라고 생각해도 되지만 엄밀히는 다른 개념입니다.
#include <cstdio>
// 배열 선언 형식은:
// '타입' '배열 이름'['배열 길이'] = {'타입에 맞는 값', ..., '배열 길이 - 1개만큼 작성할 것'};
int a[2] = {-1, 1};
float b[3] = {0.5f, 2.4f, 3.6f};
bool c[4]; // 값을 초기화하지 않으면 컴파일되지 않는다.
char d[5] = {'h', 'e', 'l', 'l', 'o'};
int e[] = {1, 2, 3, 4, 5}; // e의 길이는 자동으로 5가 된다.
// 값 꺼내오기
// 배열의 순서는 0부터 시작하므로 a[0]은 -1, a[1]은 1이다.
int main() {
printf("%d", a[1]);
return 0;
}
// 결과: 1
작업을 수행하기 위해 설계된 코드의 집합으로, Arduino에서 썼던 println()
, Processing의 rect()
등은 모두 함수입니다. 함수의 목적에 따라 입력 값과 출력 값 (Return 값)이 있을 수 있습니다. 함수를 잘 쓰면 코드의 양이 획기적으로 줄고 가독성이 좋아집니다.
#pragma once
#include <cstdio>
/**
* 함수 선언 방법
* 'Return하는 값의 타입' 함수 이름(매개변수...) {}
* @param {int} a b와 함께 더할 값
* @param {int} b a와 함께 더할 값
*/
int sum(int a, int b)
{
// 매개변수로 선언된 a와 b는 이 sum 함수 밖에서 사용할 수 없음
return a + b;
}
/**
* 함수가 값을 Return하지 않을 때 void를 사용합니다.
* 매개 변수는 꼭 있어야만 하는 것은 아닙니다.
*/
void PrintSum()
{
// 다른 함수를 불러올 때에는 함수 이름과 매개변수에 들어갈 값을 입력한다.
int Value = sum(1, 2);
printf("%d", Value);
}
OOP (Object-Oriented Programming), 즉 객체지향 프로그래밍에서 사용되는 중요한 개념으로, 객체를 정의하기 위해 변수와 방법을 정의하는 템플릿입니다.
예시로, 사람이라는 종이 있습니다. 여기에는 사람의 장기, 사지 등 모든 사람들의 기본적인 공통점이 정리되어 있습니다. 하지만 인종, 더 나아가 개개인에 따라 세부적인 특성은 모두 다릅니다. 여기서 사람이라는 종은 기본적인 템플릿이고, 이 사람이라는 템플릿을 기반으로 개개인에게 차이를 두는 것입니다.
클래스는 세부적인 개념으로 파생될 수 있습니다. 위 예시로는 사람이라는 종을 프로그램에서 클래스라고 가정했을 때 사람 그 자체는 부모 클래스라고 부릅니다. 여기서 파생되는 황인, 백인, 흑인, 더 나아가 개개인이라는 개체로 분류되는데, 이를 자식 클래스로 비유할 수 있습니다. 상위 클래스의 공통점을 모두 가진 채 자식 클래스로 파생되는 것을 상속 (Inherit), 또는 확장 (Extend)라고 부릅니다. 상속된다고 해서 부모 클래스는 상속해준 기능을 잃는 것이 아니라 공유하는 것입니다.
=
: 등호를 기준으로 오른쪽의 값을 왼쪽에 적용합니다.
bool a;
a = true; // a에 true를 할당한다.
int b;
b = 10; // b에 10을 할당한다.
float c = 10.5f; // float 타입의 변수 c를 선언하고 10.5를 대입한다.
+
: 덧셈-
: 뺄셈*
: 곱셈/
: 나눗셈%
: 나눗셈의 나머지#include <cstdio>
int main(void) {
int a;
a = 1 + 2;
printf("%d", a); // 결과는 3
a = 1 - 2;
printf("%d", a); // 결과는 -1
a = 3 * 8;
printf("%d", a); // 결과는 24
a = 3 / 8;
printf("%d", a); // 결과는 0 (a는 정수 변수이므로 0.375에서 소수점이 사라진다)
a = 3 % 8;
printf("%d", a); // 결과는 3
// 지금까지 a의 값은 3입니다.
a = a + 1; // a (3) + 1의 값을 a에 대입한다. 결과는 4
a += 1; // a = a + 1과 동일
return 0;
}
현재 변수의 값을 사용한 후 변수를 증감시킵니다.
int a = 5;
int b = a++; // b에는 5가 할당되고, a는 6이 됨.
변수를 증감시킨 후 그 값을 사용합니다.
int a = 5;
int b = ++a; // a는 6으로 증가하고, b에도 6이 할당됨.
아래 예제에서 a++
는 현재 값을 사용한 후 증가하고, ++a
는 먼저 증가한 후에 값을 사용합니다. 결과적으로 result1
과 result2
의 값이 다릅니다.
int a = 5;
int b = 10;
int result1 = a++ + b; // result1은 15가 되고, a는 6이 됨.
int result2 = ++a + b; // a는 7이 되고, result2는 17이 됨.
<
및 >
: 초과나 미만으로 좌우의 값을 비교했을 때 참이면 true
를, 거짓이면 false
를 반환합니다.
3 < 4 == true
3 > 4 == false
<=
및 >=
: 이상이나 이하로 좌우의 값을 비교했을 때 참이면 true
를, 거짓이면 false
를 반환합니다.
3 >= 4 == false
3 >= 3 == true
3 <= 4 == true
==
: 좌우의 값을 비교하고 같으면 true
를, 다르면 false
를 반환합니다.
int
형 변수 a
에 4가 저장되었을 때 a == 4
는 true
, a == 10
은 false
를 반환합니다.!=
: 좌우의 값을 비교하고 다르면 true
를, 같으면 false
를 반환합니다.true
와 false
두 가지를 비교할 때
&&
): 양쪽 다 true
일 때 true
를 반환합니다.
true && true == true
true && false == false
false && false == false
||
): 둘 중 하나만 true
여도 true
를 반환합니다.
true || false == true
true || true == true
false || false == false
true
의 개수가 홀수일 때 true
를 반환합니다.C 언어 및 C++에서 사용하는 일종의 레퍼런스로, 주로 외부 소스 파일에 정의된 변수나 함수를 쓰기 위해 만들어졌습니다. 크게 컴파일러가 기본적으로 지원하는 표준 헤더와 사용자가 임의로 만든 사용자 헤더로 나눠집니다.
// 표준 헤더 선언
#include <표준 헤더 경로>
// 표준 헤더 선언 예시
#include <iostream> // C++에서 헤더 선언
// 사용자 헤더 선언
#include "사용자 헤더 경로"
// 사용자 헤더 선언 예시
#include "Core/RSTool.h"
직역하면 정의한다는 뜻으로, 컴파일 직전 단계에서 코드에 작성된 define
의 매크로 이름을 매크로 본체로 모두 치환합니다.
#define RAW_APP_ID "480"
// ...
static constexpr const char* APP_ID = RAW_APP_ID;
// RAW_APP_ID는 "480"으로 치환되므로 APP_ID는 "480"이 됨
컴파일러에게 직접적으로 지시할 때 사용하는 지시문입니다.
#pragma once
는 헤더 파일의 중복 include
를 막아줍니다.
다음은 Main.h
는 iostream
가 두 번 포함되어 네임스페이스, 변수 및 함수명 중복 등의 이유로 컴파일이 되지 않는 예제입니다.
// Hello.h
#include <iostream>
/* ... */
// Main.h
#include <iostream>
#include "Hello.h"
/* ... */
따라서 #pragma once
가 있으면 iostream
의 중복 포함을 방지합니다.
// Hello.h
#include <iostream>
/* ... */
// Main.h
#pragma once
#include <iostream>
#include "Hello.h"
/* ... */
#pragma warning
은 특정한 컴파일러 경고를 무시합니다.
아래 경고 메시지는 보안의 이유로 deprecated된 기존에 만들어진 표준 헤더의 함수 (scanf
)를 호출할 때 발생하는 경고입니다.
error C4996: 'scanf': This function or variable may be unsafe. Consider using scanf_s instead. To disable deprecation, use _CRT_SECURE_NO_WARNINGS.
#pragma warning(disable: 4996)
#include <cstdio>
int main()
{
int a;
scanf("%d", &a); // scanf를 사용하면 C4996 경고가 발생하며 scanf_s로 선회할 것을 권장하나 #pragma warning으로 C4996 경고는 컴파일 로그에 보여지지 않음
return 0;
}
return
은 함수 내에서 결과값을 전달하는 용도로 알려져 있지만 그 함수의 동작을 return
이 실행된 시점에서 종료합니다.
void ReturnExample()
{
int a = 1;
if(a == 1) return;
// 4번쨰 줄에서 return되어 아래에 있는 코드는 실행되지 않음
int b;
b = 10;
/* ... */
}
break
는 후술할 조건문이나 반복문을 강제로 탈출할 때 사용합니다. 자세한 내용은 조건문과 반복문 섹션에서 알아봅니다.
if
안에 들어가는 값이 참일 때 {}
내의 코드를 실행합니다.
#include <iostream>
void main() {
int a = 1; // 정수형 변수 a를 선언하고 1로 초기화
if(a == 1) {
std::cout << a << std::endl; // a가 1이면 a의 값을 출력한다
}
}
/* 결과는
1
*/
if
의 조건이 맞지 않을 때 같은 변수로 다른 조건을 제시하여 그 값이 참이면 {}
내의 코드를 실행합니다.
#include <iostream>
void main() {
int a = 3; // 정수형 변수 a를 선언하고 3으로 초기화
if(a == 1) {
std::cout << a << std::endl; // a가 1이면 a의 값을 출력한다
}
else if(a == 3) {
std::cout << a << std::endl; // a가 1은 아니고 3일 때 a의 값을 출력한다
}
}
/* 결과는
3
*/
if
나 else if
의 조건까지 다 맞지 않을 때 {}
내의 코드를 실행합니다.
#include <iostream>
void main() {
int a = 1; // 정수형 변수 a를 선언하고 1로 초기화
if(a == 3) {
std::cout << a << std::endl; // a가 3이면 a의 값을 출력한다
}
else {
std::cout << "Else" << std::endl; // a가 3이 아니면 Else를 출력한다
}
}
/* 결과는
Else
*/
switch
에 들어가는 어떤 한 변수에 대해서 case
에 맞는 값일 때 코드를 실행하고 조건이 다 맞지 않는다면 default
를 실행합니다.
#include <cstdio>
int main() {
int a = 1; // 정수형 변수 a를 선언하고 1로 초기화
switch(a) {
case 1: // if(a == 1)
printf("a == 1\n");
break;
case 2: // if(a == 2)
printf("a == 2\n");
break;
case 3: // if(a == 3)
printf("a == 3\n");
break;
default: // else
printf("뭣도 아님");
}
return 0;
}
/* 결과는
a == 1
*/
break
문을 사용하지 않으면 코드는 다음과 같이 동작할 것입니다.
#include <cstdio>
int main() {
int a = 1; // 정수형 변수 a를 선언하고 1로 초기화
switch(a) {
case 1:
printf("a == 1\n");
// break가 사용될 때까지 switch-case 내 코드는 계속하여 순차적으로 실행됨
case 2:
printf("a == 2\n");
case 3:
printf("a == 3\n");
default:
printf("뭣도 아님\n");
}
return 0;
}
/* 결과는
a == 1
a == 2
a == 3
뭣도 아님
*/
while 내 조건이 true일 때 {}
내의 코드를 계속해서 반복합니다.
#include <iostream>
int main() {
int a = 0; // 정수형 변수 a를 선언하고 0으로 초기화
while(a <= 10) { // a가 10 이하인 경우 계속 반복
std::cout << a << " "; // a의 값을 출력
a += 1; // 매 반복마다 a에 1을 더함
}
return 0;
}
/* 결과는
0 1 2 3 4 5 6 7 8 9 10
*/
이는 break
를 사용하여 다음과 같이 나타낼 수도 있습니다.
#include <iostream>
int main() {
int a = 0; // 정수형 변수 a를 선언하고 0으로 초기화
while(true) { // a가 10 이하인 경우 계속 반복
if(a > 10) break; // a가 10보다 커지면 반복문 탈출
std::cout << a << " "; // a의 값을 출력
a += 1; // 매 반복마다 a에 1을 더함
}
return 0;
}
/* 결과는
0 1 2 3 4 5 6 7 8 9 10
*/
변수 선언, 변수의 범위, 변수의 증감을 직관적으로 일체화한 반복문압니다.
#include <iostream>
int main() {
int i;
for(i=0; i<=10; i++) { // i는 0부터 10 이하까지 1씩 증가하며, 11번 반복한다
std::cout << i << " ";
}
// 결과: 0 1 2 3 4 5 6 7 8 9 10
for(int j=0; j<=10; j++) {
std::cout << j << " ";
}
// for문 사용 시 선언한 변수 j는 for문 외부에서 사용할 수 없음
// for문을 사용하여 배열 반복하기
int array[5] = { 1, 2, 3, 4, 5 };
for(int k=0; k<5; k++) {
std::cout << array[k] << " ";
}
return 0;
// 결과: 1 2 3 4 5
}
배열의 길이만큼 반복하며 n번째 반복 주기에서 n번째 배열 값을 반환합니다.
// C#에서의 ForEach
using System;
// ...
int[] numbers = { 4, 5, 6, 1, 2, 3, -2, -1, 0 }; // numbers라는 정수형 배열에 4, 5, 6, 1, 2, 3, -2, -1, 0의 값으로 구성
foreach(int n in numbers) { //
System.Console.Write("{0} ", n);
}
/* 결과는
4 5 6 1 2 3 -2 -1 0
*/
// C++에서의 ForEach (C++11부터 지원)
#include <cstdio>
int main() {
int numbers[] = { 4, 5, 6, 1, 2, 3, -2, -1, 0 }; // numbers라는 정수형 배열에 4, 5, 6, 1, 2, 3, -2, -1, 0의 값으로 구성
for(int n: numbers) { //
printf("%d ", n);
}
return 0;
}
/* 결과는
4 5 6 1 2 3 -2 -1 0
*/
클래스는 특정 기능을 모듈화 (또는 캡슐화)하여 하나의 단위로 묶는 것을 목표로 합니다. 또한, 세부적인 부분을 외부로 드러나지 않게 하여 모듈 간의 결합도를 떨어뜨립니다. 이렇게 겉보기로는 불편해보이게 프로그램을 작성하는 이유는 기능이 많아졌을 때 클래스끼리 정해진 방식대로 통신하지 않으면 나중에 그 기능을 수정할 때 수많은 코드를 탐색하고 다시 작성 (리팩토링)해야 할 수 있기 떄문입니다.
클래스를 사용하는 첫 번쨰 이유는 비슷한 기능을 하는 오브젝트를 묶을 수 있기 때문입니다. 예로 Q, W 스킬을 사용하는 캐릭터를 만든다고 했을 때 다음과 같이 작성하면 기준이 없어 난잡해지죠.
#include <cstdio>
#include <string>
struct Garen
{
std::string CharName;
std::string Q;
std::string W;
Garen()
{
CharName = "Garen";
Q = "Strike";
W = "Courage";
}
};
struct NunuAndWillump
{
std::string CharName;
std::string Q;
std::string W;
NunuAndWillump()
{
CharName = "NunuAndWillump";
Q = "Consume";
W = "Snowball";
}
};
/* ...다른 캐릭터도 계속 이렇게 구현 */
이처럼 비슷한 기능을 하는 오브젝트를 클래스로 묶으면 효율적으로 코드를 관리할 수 있습니다. CharName
, Q
, W
가 계속 중복적으로 선언되고 구현되므로 효율적인 코드라고 보기 어렵겠죠. 그리고 캐릭터라는 공통점이 코드에 직접적으로 명시되어 있지 않아 묶어서 관리할 수 없습니다.
Character
라는 클래스를 만들어 보겠습니다. 캐릭터 이름과 Q, W 스킬을 부여합니다.
// Character.h
#include <string>
class Character
{
public:
Character(std::string name, std::string NameQ, std::string NameW) // 클래스의 인스턴스가 생성될 때 호출되는 메서드
{
CharName = name;
Q = NameQ;
W = NameW;
}
std::string CharName;
std::string Q;
std::string W;
};
기본 틀만 미리 구현하면 단 두 줄로 Garen
과 NunuAndWillump
를 구현할 수 있는 것입니다.
#pragma once
#include <iostream>
#include <string>
#include "Character.h"
int main()
{
Character Garen("Garen", "Strike", "Courage"); // 이렇게 선언되어 바로 사용 가능한 클래스는 인스턴스라고 부른다.
Character NunuAndWillump("NunuAndWillump", "Consume", "Snowball");
std::cout << Garen.CharName; // 클래스의 내부에 접근하려면 클래스 이름.멤버 이름의 형식으로 작성
return 0;
}
Character
의 기능을 기반으로 각 캐릭터마다 고유적인 기능을 추가하여야 할 때가 있습니다. Character
라는 클래스에서 파생되어 기능을 추가하거나 변조하는 것을 상속 (Inherit 또는 Extend)라 부릅니다. 파생된 클래스에서 부모 클래스에 선언된 필드나 메서드를 정의할 필요는 없습니다. 부모 클래스의 필드 및 메서드는 모두 자식 클래스에게 유전되었으니까요.
먼저 클래스의 특성인 정보 은닉에 대해 알아보겠습니다. 이는 클래스 그 자체뿐만 아니라 클래스를 상속하는 데에서도 중요한 개념입니다.
public
: 클래스의 외부에서도 직접 접근 및 수정이 가능하도록 노출됨protected
: 클래스의 외부에서는 사용할 수 없지만 자신 및 자신을 기반을 상속된 클래스 내부에서 접근이 가능하도록 부분 노출됨private
: 클래스 자신 내부에서만 접근이 가능함// Character.h
#include <cstdio>
#include <string>
class Character
{
public:
Character(std::string name, std::string NameQ, std::string NameW) // 클래스의 인스턴스가 생성될 때 호출되는 메서드
{
CharName = name;
Q = NameQ;
W = NameW;
}
std::string CharName;
std::string Q;
std::string W;
float GetCoolDown()
{
return cooldown;
}
void Passive()
{
RunPassive();
}
protected:
int Passive;
virtual void RunPassive() // 가상 함수는 파생 클래스에서 변조가 가능함
{
printf("Running Passive");
}
private:
float cooldown = 0.f;
};
지금까지 클래스를 만들고 각각의 캐릭터에 따른 인스턴스를 만들어보았습니다. 클래스를 사용할 때 유의 사항은 클래스 멤버 변수를 클래스 외부에서 제어하려면 가급적이면 Getter
나 Setter
를 활용하는 것이 좋습니다. 먼저 Getter
와 Setter
에 대해 알아보겠습니다.
Getter
: 멤버 변수의 값을 가져온다.Setter
: 멤버 변수의 값을 지정한다.class Example
{
public:
int GetA() // Getter
{
return A;
}
void SetA(int InA) // Setter
{
A = InA;
}
private:
int a;
};
Getter
와 Setter
를 사용하는 이유는 다음과 같습니다.
즉 값이 변경될 때 수행해야 하는 작업을 일일히 값을 바꿀 때마다 구현할 필요가 없다는 것입니다.
클래스를 상속받는 과정은 다음과 같습니다.
class Character
{
/* ... */
};
class Garen: public Character
{
// Character의 멤버 또는 메서드를 모두 가져오면서 추가적인 로직 작성 가능
};
클래스를 상속하는 과정을 알아보았으니 위 예제를 기반으로 Garen
이라는 클래스를 만들어보겠습니다.
// Garen.h
#pragma once
#include <iostream>
#include <string>
#include "Character.h"
class Garen: public Character
{
public:
int ChampionIndex = 50; // Character에는 없는 Garen만의 변수
protected:
void RunPassive() override // Character의 RunPassive의 함수를 오버라이드함. 즉 Garen.RunPassive()는 "인내심 발동!"을 프린트하게 됨
{
std::cout << "인내심 발동!" << std::endl;
}
};
이를 바탕으로 public
, protected
, private
이 클래스에 미치는 영향과 상속된 클래스의 특성에 대해 알아보겠습니다.
// Main.h
#pragma once
#include <iostream>
#include <string>
#include "Character.h"
#include "Garen.h"
int main()
{
Character Platina("JaehyeokLee", "Cute", "Smart");
Garen garen;
// public 필드에 직접 접근
std::cout << Platina.CharName; // JaehyeokLee를 프린트한다
garen.Q = "Strike";
Platina.ChampionIndex = 1; // Character 클래스는 ChampionIndex가 없으므로 컴파일 에러
// protected나 private 필드는 클래스 내부 메서드에서만 접근할 수 있으므로
// 메서드를 통해 간접적으로 접근합니다.
std::cout << Platina.GetCoolDown(); // cooldown의 값을 프린트한다
garen.cooldown = 10.f; // private 필드의 변수는 직접 접근할 수 없으므로 컴파일 에러
// virtual 함수는 같은 이름의 함수도 기능을 완전히 바꿀 수 있습니다.
Platina.Passive(); // Running Passive를 프린트한다
garen.Passive(); // 인내심 발동!을 프린트한다
return 0;
}
프로그래밍 언어를 막론하고 모든 변수, 클래스, 구조체 등의 정보는 RAM에 기록됩니다. 외장 SSD에도 C:\Windows
와 같이 주소가 있는 것처럼 RAM에도 주소가 있습니다. 포인터는 이 주소를 가리키는 형식입니다.
주소를 알아내는 방법은 다음과 같습니다. 변수 및 인스턴스 이름 앞에 &
를 붙입니다.
int a; // 변수 선언
&a // a 변수의 주소 값
포인터는 이 주소를 저장하는 변수입니다. 포인터는 특정 타입의 데이터를 가리키도록 선언합니다.
int a; // 변수 선언
int* a_ptr = &a; // a 변수의 주소를 a_ptr 포인터에 할당
*a_ptr = 1; // a = 1과 동일
포인터가 가리키는 주소에 필요한 것이 할당되어 있지 않다면 nullptr
으로 간주합니다. nullptr
에 접근하는 것은 경우에 따라 프로그램에 심각한 문제를 초래할 수 있으므로 포인터에 접근할 때에는 포인터가 nullptr
인지 확인한 후 접근하는 것이 좋습니다.
int* a_ptr; // a_ptr 포인터 선언
*a_ptr = 1; // a_ptr은 어떠한 변수도 가리키지 않으므로 nullptr 상태인 포인터에 접근한 것이다.
// 포인터가 nullptr인지 체크하고 아닐 때 포인터에 접근하는 방법
if(a_ptr) *a_ptr = 1;
// 또는
if(a_ptr != nullptr) *a_ptr = 1;
일반적으로 대입 연산자는 값을 복사합니다. 클래스의 경우 아래와 같이 작성하면 b
는 a
를 복사한 것이므로 b
를 수정해도 a
에 영향을 주지 않습니다. 즉, a
와 b
는 서로 다른 인스턴스입니다.
class HelloWorld { /*...*/ }
HelloWorld a;
HelloWorld b = a;
하지만 포인터는 주소를 가리키므로 아래와 같은 경우 b
를 조작하는 것은 a
를 조작하는 것과 같습니다.
class HelloWorld { /*...*/ }
HelloWorld a;
HelloWorld* b = &a;
// 클래스 포인터의 경우 .으로 클래스 멤버에 접근하는 것이 아닌 ->으로 클래스 멤버에 접근한다.
b->DisplayClassName(); // a.DisplayClassName();와 동일