2024-09-29

「低レイヤを知りたい人のためのCコンパイラ作成入門」第六回勉強会まとめ

学習範囲

ステップ3トークナイザ導入

トークン

「単語」のことを「トークン(token)」といいます。
日本語や英語、算数の式と同じようにプログラミング言語も単語の列から成り立っています。

具体例

日本語の場合: 私 は ゆうき です 。

英語の場合: My name is yuuki .

算数の式の場合:5 + 20 - 4

トークナイズする

コンピュータが処理できるように単語の集まりからなる文字列を、空白を取り除いた単語ごとに分割することです。

列車を例にしたとき

分割したトークン列を連結リストというデータ構造で扱うときの具体例です。

トークナイズするとは連結している列車をそれぞれ車両ごとに分割することです。1両目、2両目、3両目。。。といった感じです。

トークナイズすることのメリット

文字列を意味ある塊に分けることもメリットですが、それぞれの塊ごとに型(文字、数値、記号)を定義することでより扱いやすくなります。

トークナイザを導入して改良したバージョンのコンパイラ

トークンに分割する処理のことをトークナイザといいます。

以下のコードは識別(トークナイズ)して、その後解釈をして型をつけています。それぞれのコードが何をしているか分かるようにコメントを記述しました。

※以下のコードはM1環境の場合です。

#include <ctype.h>
#include <stdarg.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// トークンの種類。
typedef enum {
  TK_RESERVED,//記号
  TK_NUM,//整数
  TK_EOF,//入力の終わり
} TokenKind;

//これは文字です、数値ですと宣言して、箱作っているようなもの。
typedef struct Token Token;

// トークン型
struct Token {
//左キー、右がプロパティの名前
  TokenKind kind; //トークンの型
  Token *next;    //次の入力トークン
  int val;        //kindがTK_NUMの場合、その数値
  char *str;      //トークン文字列
};

//現在着目しているトークン
Token *token;

//エラーを報告するための関数
//printfと同じ引数を取る
void error(char *fmt, ...) {
  va_list ap; //可変長リスト
  va_start(ap, fmt);
  vfprintf(stderr, fmt, ap);
  fprintf(stderr, "\n");
  exit(1);
}

//以下の動作。
//例。すでに列車から分割されて車両になっている。車両ひとつひとつの型を確かめている。

//次のトークンが期待している記号のときには、トークンを1つ読み進めて
//真を返す。それ以外の場合には偽を返す。
bool cunsume(char op) {
  if (token->kind != TK_RESERVED || token->str[0] != op)
    return false;
  token = token->next;
  return true;
}

//次のトークンが期待している記号のときには、トークンを1つ読み進める。
//それ以外の場合にはエラーを報告する。
void expect(char op) {
  if (token->kind != TK_RESERVED || token->str[0] != op)
    error("'%c'ではありません", op);
  token = token->next;
}

//次のトークンが数値の場合、トークンを1つ読み進めてその数値を返す。
//それ以外の場合にはエラーを報告する。
int expect_number() {
  if (token->kind != TK_NUM)
    error("数ではありません");
  int val = token->val;
  token = token->next;
  return val;

}

//今みてるtokenが終わりか。つまり入力が終わっているかを確認する。  
bool at_eof() {
  return token->kind == TK_EOF;
}

//新しいトークンを作成してcurに繋げる。

//例。new_tokenは列車を一個作る関数。
Token *new_token(TokenKind kind, Token *cur, char *str) {
  Token *tok = calloc(1, sizeof(Token));
  tok->kind = kind;
  tok->str = str;
  cur->next = tok;
  return tok;
}

//入力文字列pをトークナイズしてそれを返す
Token *tokenize(char *p) {
  Token head;
  head.next = NULL;
  Token *cur = &head;

  while (*p) {
    //空白文字をスキップ
    if (isspace(*p)) {
      p++;
      continue;
    }

    //例。車両ごとに分割する必要があるため、tokenを新しく作る必要がある

    if (*p == '+' || *p == '-') {
      cur = new_token(TK_RESERVED, cur, p++);
      continue;
    }

    //例。12 + 35 - 4 は、記号と数字とを分ける。それがなにかを定義する。

    if (isdigit(*p)) {
      cur = new_token(TK_NUM, cur,p);
      cur->val = strtol(p, &p, 10);
      continue;
    }

    // . などはエラーとなる。
    error("トークナイズできません");
  }

  //終わりの処理
  //例。車両が終わりですよと伝える。先頭からみたときに、最後部の処理が終わったか知る必要がある。
  new_token(TK_EOF, cur, p);
  return head.next;
}

int main(int argc, char **argv) {
  if (argc != 2) {
    error("引数の個数が正しくありません");
    return 1;
  }

  //トークナイズする
  //例。20 + 3 + のとき 20は 後ろにある +しか知らない。最後部を知るために辿っていく。
  token = tokenize(argv[1]);

  //アセンブリの前半部分を出力
  printf(".globl main\n");
  printf("main:\n");

  //式の最初は数でなければならないので、それをチェックして
  //最初のmov命令を出力
  printf(" mov w0, %d\n", expect_number());

  //'+<数>’あるいは`-,数`というトークンの並びを消費しつつ
  //アセンブリを出力
  while (!at_eof()) {
    if (cunsume('+')) {
      printf(" add w0, w0, %d\n", expect_number());
      continue;
    }

    expect('-');
    printf( " sub w0, w0, #%d\n", expect_number());
  }

  printf(" ret\n");
  return 0;
}

まず識別(トークナイズ)し、次に解釈することで読みやすいコードとなります。

エラーを起こす

.ピリオド(ドット)は識別に対応できていないのでエラーとなります。

tokenize 関数では、以下の3種類のトークンしか認識していないためです。

  • 空白文字:スキップします。

  • 記号:+ または - のみを TK_RESERVED トークンとして認識します。

  • 整数値:0 から 9 の数字のみを TK_NUM トークンとして認識します。

コラム

言語処理を行うときの基本として、現在は文章を単語などのの何らかの単位に区切り(トークナイズ)して、それらをベクトルに落とし込んでモデルで処理をすることが多いです。

トークンの種類

私はゆうきです。のとき

  • 単語単位のトークン

私 / は / ゆうき /です / 。

  • 文字単位のトークン

私 / は / ゆ / う / き / で / す / 。

  • サブワード単位のトークン

単語単位からさらに分割したものをサブワードといいます。
東京オリンピックなら

東京 / ##オリンピック /

「##」とついているのは前に何かのサブワードがつく事を表しています。

LLMではこの分割された単語に一意のID(数字)を割り当てて、この数字を使ってベクトル計算をし、文字の類似度をコンピュータで学習することができるようになります。

LLM(大規模言語モデル)とは

膨大なテキストデータと高度なディープラーニング技術を用いて構築された言語モデルです。大規模言語モデルは、人間に近い流暢な会話が可能であり、自然言語を用いたさまざまな処理を高精度で行えることから世界中で注目を集めています。ChatGPTもその一つです。

トークナイザの重要性

自然言語処理において、適切なトークナイザーの選択は、モデルの性能向上に大きく貢献します。英語は空白で単語が切られているので分割はしやすいですが、特に日本語のような空白で意味が区切られていない言語では、トークン化の精度が重要です。

トークナイザーは、自然言語処理の基盤であり、適切なトークナイズが行われることでモデルのパフォーマンスが飛躍的に向上します。

参考文献

大規模自然言語モデル(LLM)で利用されるトークナイザーについて

LLm(大規模言語モデル)とは?生成AIとの違いや仕組みを解説

日本語LLMにおけるトークナイザーの重要性

まとめ

まず識別(トークナイズ)してその後、各トークンに対して型をつけることでコンピュータが処理しやすい形になります。

それに関連して生成AIの内部処理やモデル作成について調べることで、よりトークンへの理解が深められるかと思います。