Post

WIL - 24.12.29 ~ 25.01.05

WIL

WIL - 24.12.29 ~ 25.01.05

일주일 동안 공부한 내용들

이제부터 일요일은 월~토 까지 공부한 내용들을 복습하고 정리하는 글을 쓰기로 했다.

이번 주는 C++에서 파일 입출력을 하는 방법을 공부했고, 컴파일 타임 중 전처리, 컴파일 단계에 대하여 공부했다.

파일 입출력 fstream

fstream은 파일 입출력을 위한 라이브러리로, ifstream, ofstream이 있다.

fstream 클래스들은 매개변수를 이용해 파일의 타입을 텍스트 또는 바이너리로 열 수 있다.

ofstream의 경우 두 번째 매개 변수로 쓰기 속성을 설정 가능하고, 비트 플래그로 이루어져 있어 여러 속성들을 조합할 수 있다.

텍스트 모드와 바이너리 모드

텍스트 모드와 바이너리 모드의 차이는 구분자의 유무다.

텍스트 모드의 경우 줄넘김(\n)을 이용해 구분자를 구분하지만, 바이너리 모드는 모든 입력을 그대로 처리한다.

때문에 텍스트를 쓰고 읽을 땐, 텍스트 모드. 텍스트 이외의 데이터를 저장할 때는 바이너리 모드로 쓰고 읽는 것이 좋다.

파일을 읽어오는 3가지 방법

C++에서 파일을 찾는 기능은 3가지가 있다.

1
2
3
4
5
6
7
8
9
10
11
12
void FileManager::GetFileNamesWithFileSystem(const string& extension, vector<string>& fileNames) // C++ 17 이상
{
	string dir = "./";  //filesystem은 확장자명으로 필터링하지 못함

	for (const auto iter : fs::directory_iterator(dir))
	{
		if (iter.path().extension() == extension) //.확장자 로 비교해야함
		{
			fileNames.push_back(iter.path().filename().string());
		}
	}
}

FileManagerC++ 17 이상 부터 사용 가능한 기능이며, 플랫폼에 독립적이라 클로스 플랫폼에 적합하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void FileManager::GetFileNamesWith_findfirst(const string& path, vector<string>& fileNames) // Windows 전용, 표준 라이브러리 아님
{
	struct _finddata_t fd;
	intptr_t handle;

	if ((handle = _findfirst(path.c_str(), &fd)) != -1L)
	{
		do
		{
			fileNames.push_back(fd.name);
		} while (_findnext(handle, &fd) == 0);

		_findclose(handle);
	}
}

_findfirst 는 표준 라이브러리가 아니고, Microsoft Visual C++ 컴파일러에서 제공하는 함수로, 구조체를 이용해 파일을 탐색한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void FileManager::GetFileNamesWithWinAPI(const string& path, vector<string>& fileNames) // Windows 전용, 빠르고 고급 제어가 가능
{
	WIN32_FIND_DATAA findFileData;
	HANDLE hFind = FindFirstFileA(path.c_str(), &findFileData);
	
	if (hFind != INVALID_HANDLE_VALUE)
	{
		do
		{
			fileNames.push_back(findFileData.cFileName);
		} while (FindNextFileA(hFind, &findFileData) != 0);
	
		FindClose(hFind);
	}
}

WinApi 도 구조체를 이용한 방법을 사용하지만, _findfirst보다 기능이 많다.

fstream 라이브러리의 매크로

export : C++ 20부터 지원하는 기능으로, 이 라이브러리를 모듈로 사용하는 외부에선 export 키워드가 붙은 요소들만 사용이 가능하다.

EXPORT_STD : fstream에서 현재 사용 중인 C++ 의 버전을 확인해 export를 사용할 지 정해주는 매크로다.

1
2
3
4
5
6
7
8
#ifdef _BUILD_STD_MODULE
#if !_HAS_CXX20
#error C++ 20 이상만 사용 가능
#endif
#define _EXPORT_STD export
#else
#define _EXPORT_STD 
#endif

using : #define 처럼 키워드를 치환하지만 전처리 단계에서 치환하지 않고, 컴파일 단계에서 치환이 이루어진다.

덕분에 타입 안정성을 보장 받는다. 또한 디버깅이 수월하고 템플릿에서도 사용이 가능하다.

컴파일 타임

컴파일 타임은 개발자가 작성한 소스코드를 기계어로 변환하는 과정을 말한다.

전처리 단계 > 컴파일 단계 > 어셈블리 단계 > 링킹 단계로 이루어져 있다.

전처리 단계

전처리 단계에선 컴파일러가 컴파일을 수행하기 전, 소스 코드를 처리한다.

이 때 처리된 코드는 expanded code 라고 불리며 .i 확장자를 가진다.

사용하는 컴파일러에 따라 다르지만 cl 컴파일러를 사용한다면 /P 옵션을 사용하여 전처리 단계까지 수행하고 나온 결과를 출력할 수 있다.

자주 사용하는 전처리 지시문

지시문목적
#include소스 코드의 헤더 파일을 연결
#define매크로 상수를 생성
#undef이미 정의된 매크로 삭제
#ifdef / #ifndef매크로 존재 여부로 분기를 나눔
#if / #elif / #else / #endif분기문
#error컴파일 프로세스를 중단하고 오류 알림 생성
#warning컴파일 동안 경고 알림을 표시(cl은 사용 못함함)
#pragma컴파일러 특정 기능을 호출할 때 사용

#pragma 의 경우, 컴파일 경고를 무시하거나, 헤더 중복 방지, 구조체의 메모리 정렬 방식 등을 설정할 수 있다.

#define

#define 은 전처리 단계에서 소스 코드에 사용된 키워드를 치환하는 기능을 수행한다.

이 때 치환은 문자열 치환을 기반으로 이루어져 있다.

전처리 단계에서 치환하므로, 타입 안정성을 보장 받지 못하고, 연산자 우선 순위 문제가 발생할 수 있으므로 주의해야한다.

1
2
3
4
5
6
#define SQUARE(x) (x * x)

int main()
{
	int result = SQUARE(1 + 2); // (1 + 2 * 1 + 2) 가 되니 주의해야 한다.
}

대신 키워드를 치환하는 방식이기에, 메모리에 저장되는 것이 아닌 코드에 삽입 되어 성능상 이점을 얻게 된다.

inline 함수

inline 함수는 헤더 파일에 정의하는 짧은 함수다.

일반적인 함수처럼 호출하는 것이 아닌 함수 본문이 코드에 삽입되는 방식으로 사용된다.

inline 키워드로 함수를 인라인 함수로 사용하고 싶다고 컴파일러에게 알릴 수 있고, 실제로 ‘inline’ 함수로 만들지 정하는 것은 컴파일러다.

매크로 함수와의 차이로는 타입 안정성을 보장한다, 디버깅이 좀 더 수월하다 등이 있다.

컴파일 단계

전처리된 소스코드를 중간 코드 또는 어셈블리 언어로 치환하는 단계다.

중간 코드

중간 코드는 플랫폼에 독립적인 코드로, 하드웨어와 운영체제에 종속되지 않는 코드다.

3주소 코드, 세미콜론 코드 같은 형식이 중간 코드다.

어셈블리 코드

어셈블리 코드는 특정 하드웨어에 맞는 기계어에 대응하는 저수준 코드다.

소스 코드를 편집하는 과정은 어휘 분석, 구문 분석, 의미 분석, 중간 코드 생성, 최적화 과정으로 이루어져 있다.

어휘 분석 단계

소스 코드를 어휘 항목(의미있는 단어)으로 나누어 토큰을 생성하는 단계다.

토큰 : 토큰 타입과 토큰 값으로 이루어져 있으며, 각 토큰의 타입마다 규칙이 존재한다.

토큰은 언어에 따라 달라지고, 컴파일러에 따라서도 달라질 수 있다.

정규 표현식

소스 코드를 토큰화 시킬 때 정규 표현식을 사용한다.

정규 표현식은 텍스트에서 패턴을 찾을 수 있는 식이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void RegularExpression()
{
	string text = "int x = 10 + 5;";

	regex token_pattern(R"(int|x|[0-9]+|[+\-*/=;])");

	sregex_iterator iter(text.begin(), text.end(), token_pattern);
	std::sregex_iterator end;

	while (iter != end)
	{
		cout << "Token: " << iter->str() << endl;
		++iter;
	}
}

정규 표현식은 순서를 가지기에 패턴에 대해 우선순위를 설정할 수 있다.
a(b|c)*d

  1. 시작 문자열은 반드시 a 여야 한다.
  2. bc 가 0번 이상 반복 가능하다.
  3. 문자열의 끝은 반드시 ‘d` 만 가능하다.

유한 상태 오토마타

유한한 수의 상태를 가지는 추상적인 계산 모델로, 순서를 가지고 하나의 초기 상태가 현재 상태에 따라 변화하는 것이 정규 표현식과 유사하다.

정규 표현식과 유한 상태 오토마타는 상호 변환이 가능하기에 유한 상태 오토마타를 이용해 토큰을 생성한다.

어휘 단계에선 정의되지 않은 기호나 잘못된 리터럴에 대한 오류를 검출할 수 있다.

구문 분석

구문 분석 단계는 어휘 분석 단계에서 만든 토큰들의 문법을 검사하는 단계다.

문법을 분석하는 모듈을 파서(Parser) 라고 하고, 파서가 토큰들을 가지고 파스 트리를 생성한다.

이 때는 토큰의 타입과 순서가 문법에 일치하는 지 확인하는 단계이므로 토큰 값은 중요하지 않다.

파서는 보편적, 하향식, 상향식 파서가 있는데, 보편적 파서는 모든 문법을 파싱 가능하지만 성능이 좋지 않아 상용 컴파일러에선 사용이 힘들다.

문맥 자유 문법

파서는 언어 정의에 이용된 문법을 이해해야 문법적 오류를 검출할 수 있다.

C/C++의 경우 문맥 자유 문법(CFG) 기반으로 정의되어 있다.

CFGV T P S 순서쌍으로 정의된 문법이다.

V : 비단말 기호 T : 단말 기호 P : 생성 규칙 S : 시작 기호

  • 단말 기호 : 의미를 가진 기호 중, 더 이상 쪼갤 수 없는 기호. 파싱이 종료되고 난 후에는 단말 기호만 남는다.
    • ex) if, else, +
  • 비단말 기호 : 다른 기호로 변환이 가능한 기호들로, 언어의 구조적 계층을 정의할 때 사용된다.
    • ex) prog, stmt_list, stmt

문맥 자유 문법은 좌변에 하나의 비단말 기호만 올 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int a = 10; // 소스코드

[KEYWORD:int, IDENTIFIER:a, OPERATOR:=, LITERAL:10, DELIMITER:;] //토큰 생성

//유도 과정
prog -> stmt
stmt -> decl ;
decl -> type id = value
type - > int
id -> a
operator -> =
value = 10
delimiter -> ;

//최종 결과
prog -> stmt_list -> stmt -> decl ; -> type id = value ; -> int a = number ; -> int a = 10 ;

파스 트리

파스 트리는 파싱을 하며 각 단계의 확장을 노드로 표현한 트리다.

트리의 간선은 확장 규칙이고, 트리의 끝에는 단말 기호들이 배치된다.

한 문법을 기준으로 만약 동일 문자열에 대해 2개 이상의 트리가 생성되면 그 문법은 모호한 문법이라고 부른다.

하향식 파싱

가장 왼쪽 비단말 기호부터 유도가 이루어지는 방식은 하향식 파싱 방법과 동일하며, 재귀적 하강 파싱 또는 예측 파싱으로 구현 가능하다.

재귀적 하강 파싱은 백트랙킹을 해야하는 단점이 존재하는데, 이를 해결한 파서가 LL(1) 파서이다.

이 파서는 비단말 기호에 대한 단일 규칙만 선택하도록 문법을 조정할 수 있다.

때문에 비단말 기호를 읽고, 그 다음 어떤 규칙이 올 지 예측할 수 있으며, 백트래킹도 피할 수 있게 되었다.

단일 규칙을 이용하기 때문에 LL(1)은 스택을 이용해 구문을 분석할 수 있다.

마무리

여기까지가 공부한 내용의 요약이다.

사실 컴파일 단계의 끝까지 하고 싶었으나, 집중도 못했고, 토큰화와 파싱을 구현하는데 시간이 너무 오래 걸리기도 했다.

다음주는 컴파일 공부와 C++ 공부를 병행할 예정이다.

컴파일 단계를 끝내고, 많이 부족하다고 생각하는 템플릿과 함수 객체, 함수 포인터, 람다에 대해 공부하고 싶다.

시간이 된다면 유한 상태 오토마타와 파스 트리도 구현해볼 예정이다.

언리얼도 빨리 공부해야하는데 하루가 너무 짧다.

This post is licensed under CC BY 4.0 by the author.