프로젝트/컴파일러 만들기

Scanner

춤추는수달 2024. 4. 13. 20:52

Scanner란?

Scanner는 소스코드의 모든 글자들을 읽어(Scan) Token의 형태로 이해하는 것을 말합니다.

소스코드를 Scanner에 넣으면 Token 목록, 즉 TokenList가 출력됩니다.

 

여기서 말하는 Token이란, 소스 코드 상에서 의미가 있는 단어 혹은 글자를 말합니다.

예를 들어 다음  c++ 소스 코드 상에서 '^'라는 글자는 아무 의미 없지만,

그 외에 int, main, (, ), {, } 와 같은 글자들은 모두 의미가 있고 하는 역할이 있습니다. 

int main()
{
	^
}

Scanner는 이렇게 의미 있는 글자들을 단어로 묶어 하나의 Token으로 만듭니다. 

그리고 모든 글자를 읽어서 Token List를 만듭니다.

반대로 의미 없는 글자를 발견하면 컴파일 에러를 발생시킵니다.

 

데이터 정의

1. Token 타입

Scanner는 의미있는 단어들의 모음을 만든다고 했습니다.

그렇다면 어떤 글자 혹은 단어가 의미있는지 아닌지는 어떻게 구분할까요?

그것은 우리가 컴파일할 언어에서 사용되느냐, 안되느냐를 판단해야 합니다.

따라서 사용될 단어, 글자들의 의미와 문자를 모두 미리 정의해놓을 필요가 있습니다.

제가 컴파일할, 제가 스스로 만든 언어에서는 아래와 같이 정의해 놓았습니다.

static map<wstring, TokenKind> StringToTokenType =
{
    {L"#unknown", TokenKind::Unknown},
    {L"#EndOfToken", TokenKind::EndOfToken},

    {L"없음", TokenKind::NullLiteral},
    {L"참", TokenKind::TrueLiteral},
    {L"거짓", TokenKind::FalseLiteral},

    {L"영", TokenKind::NumberLiteral},
    {L"일", TokenKind::NumberLiteral},
    {L"둘", TokenKind::NumberLiteral},
    {L"삼", TokenKind::NumberLiteral},
    {L"사", TokenKind::NumberLiteral},
    {L"오", TokenKind::NumberLiteral},
    {L"육", TokenKind::NumberLiteral},
    {L"칠", TokenKind::NumberLiteral},

   	// .... 중략

    {L",", TokenKind::Comma},
    {L":", TokenKind::Colon},
    {L".", TokenKind::Period},
    {L"(", TokenKind::LeftParen},
    {L")", TokenKind::RightParen},
    {L"{", TokenKind::LeftBrace},
    {L"}", TokenKind::RightBrace},
    {L"[", TokenKind::LeftBraket},
    {L"]", TokenKind::RightBraket}

};

이렇게 {글자, 의미}의 형태로 map을 정의했습니다.

참고로 TokenKind는 enum class 입니다. 이것도 StringToTokenType 맵 정의 이전에 미리 정의해 놓았습니다.

 

추가로, StringToTokenType과 반대되는, {의미, 글자} 형태의 맵도 정의하고, 의미와 글자를 서로 교체할 수 있는 함수도 만들어 주겠습니다. 단순히 편의를 위해서 입니다.

static auto TypeToString = [] {
    map<TokenKind, wstring> result;
    for (auto& item : StringToTokenType)
        result[item.second] = item.first;
    return result;
}();


TokenKind StrToType(wstring string) {
    if (StringToTokenType.count(string))
        return StringToTokenType.at(string);
    return TokenKind::Unknown;
}

wstring TypeToStr(TokenKind type) {
    if (TypeToString.count(type))
        return TypeToString.at(type);
    return L"";
}

 

2. Token

Token은 string과 TokenType을 멤버로 가지는 struct 입니다. 소스코드에서 하나의 단어를 의미합니다.

예를 들어 C++의 ++ 연산자를 Token으로 만들면, Token{ TokenType::Increaser, "++"} 와 같은 객체가 될 것입니다.

2. 글자 타입

보통 토큰의 타입마다 사용되어야 하는 글자 타입, 가장 첫 번째로 와야하는 글자 타입이 정해져 있습니다.

예를 들어 C++에서 식별자는 무조건 알파벳 혹은 언더바(_)로 시작합니다. 

따라서 글자를 읽고 어떤 글자인지 판별하기 위해 글자 타입을 정의해 주어야 합니다. 

저는 아래와 같이 정의 했습니다.

enum class CharType
{
    Unknown,
    WhiteSpace,
    NumberLiteral, // 일이삼사오육칠팔구십백천만억조점
    StringLiteral, // " 으로 시작 " 으로 끝.
    Identifier, // ' 으로 시작 ' 으로 끝
    Korean, // 0xAC00 <= c && c <= 0xD7A3
    Punctuator, //(), {},[]
};

제가 만들고 싶은 언어는 대부분 한글로 이루어져있기 때문에, CharType::Korean 이 굉장히 많은 역할을 하게 됩니다...

 

Scanner 동작 원리

필요한 데이터가 정의되었으니, 이를 활용해 Scanner를 만들어 봅시다.

Scanner는 소스코드에서 단어를 읽고 Token으로 만든다고 했습니다.

그 과정을 간략히 소개하면 아래와 같습니다.

  1. 단어를 읽으려면 먼저 하나의 글자를 읽어 글자타입을 확인합니다.
  2.  1.에서 읽은 첫 번째 글자에 따라 이 글자가 어떤 타입단어일지 판별해야 합니다.
    • e.g) 읽은 단어의 첫 글자가 알파벳이었다면, 해당 단어는 키워드거나 식별자거나 둘 중 하나일 것입니다.
  3. 첫 글자로 판별한 단어의 타입이 맞는지 이후 이어지는 글자들을 읽어서 검증하고, 정확한 단어의 타입을 판별합니다.
  4. 단어의 타입을 판별했다면, Token 객체를 만들어서 TokenList에 삽입합니다.
  5. 소스코드 끝까지 읽어들이며 반복합니다.

이해를 돕기 위한 예시

이것만 보고 완벽히 이해하기는 힘들 것이라 생각됩니다. 

때문에 C++로 간단한 예를 들어보겠습니다.

{ a++;}

라는 아주 간단한 코드를 Scanner로 Scan해보겠습니다.

 

Scan하기 전에 간단하게 CharType{ Alphabet, Punctuator, Operator, Unknown, WhiteSpace}와

TokenType{Identifier, Increase, LeftBrace, RightBrace, Semicolon }을 정의합니다.

 

먼저 읽히는 글자는 '{' 입니다. 

이 글자는 CharType::Punctuator(구분자)에 해당한다고 판별했습니다.

Punctuator가 단어의 첫 글자인 경우에는, 이후에 붙는 글자가 없이, 하나의 글자만 읽고 단어로 봅니다.

따라서 Token{ TokenType::LeftBrace , "{" } 를 생성하고 TokenList에 삽입합니다.

 

다음 글자인 'a'를 읽습니다. 

이 글자는 CharType::Alphabet에 해당한다고 판별했습니다.

Alphabet이 첫 글자인 경우, 이 단어는 키워드이거나 식별자입니다. 이후 Alphabet과 '_'가 나오는 동안 글자를 읽어들이고 이를 하나의 단어로 봅니다. 여기서는 "a"라는 문자열이 하나의 단어입니다.

"a"가 키워드인지 미리 정의해둔 Map을 통해 확인합니다. 

"a"라는 키워드는 없다는 것을 확인했으니, 이것은 식별자입니다. 

따라서 Token{TokenType::Identifier ,"a"}를 생성하고 TokenList에 삽입합니다.

 

다음 글자인 '+'를 읽습니다.

이 글자는 CharType::Operator 입니다. 

CharType::Operator가 첫 글자인 단어는 연산자입니다. 이후 CharType::Operator가 나오는 동안 글자를 읽어들입니다.

연산자의 경우 읽어들인 문자열 "++"에서 마지막 글자를 하나씩 빼가며 Map에 존재하는지 확인합니다. 

"++"는 Map에 TokenType::Increaser라고 등록되어 있었습니다.

따라서 Token{TokenType::Increaser, "++"}를 생성하고 TokenList에 넣습니다.

 

이후 나오는 ';'와 '}' 기호도 앞서 보였던 '{' 글자와 다르지 않습니다.

 

여기서 주의해야할 점은, 단어의 타입마다 판별해내는 방법이 약간씩 다르다는 점입니다.

Operator의 경우 일단 단어의 끝까지 읽어 문자열을 구성하고, 끝에서부터 하나씩 빼가며 map을 참조해 정확한 TokenType을 판별합니다.

하지만 Alphabet의 경우 단어의 끝까지 읽어 문자열을 구성하고, 해당 문자열이 map에 존재하는지 한 번 검사해서 Identifier인지 Keyword인지 판별합니다.

이처럼 자신이 만들 언어의 문법에 따라 스스로 생각하고 TokenType 판별 로직을 만들어주어야 합니다.

 

 

'프로젝트 > 컴파일러 만들기' 카테고리의 다른 글

프로그래밍 언어 문법  (0) 2024.03.29
컴파일러를 만들어보자.  (0) 2024.03.28