ヨーダ記法をlibclang使って置換してみた

はじめに

こんにちは。ファームウェアエンジニアチーム、インターンの後藤です。 修士論文の発表と提出が無事に終わり、毎日が幸せいっぱいです。

レガシーコードリファクタの一環として、ヨーダ記法を置換しようという話になりました。 今回はlibclangを用いてヨーダ記法を置換するお話です。

ヨーダ記法とは

ヨーダ記法とは比較演算子==, !=のうち、左辺に固定値、右辺に変数がある記法のことです。 例えば以下のような条件式が挙げられます。

if (42 == everything) { ... }

C, C++では代入演算子=は値として評価できるため、if文の比較内部でも利用可能です。*1 このことで比較演算子==と代入演算子=を間違えてしまうことがあり、目に優しくないデバッグを強いられることがあります。 このような間違いを防ぐための記法としてヨーダ記法が生まれました。 誤った代入演算子の場合、コンパイラは代入演算子の左辺に変数があることを期待するため、コンパイル時にエラーとすることができます。

しかし、現在ではこのような記法を用いずとも、コンパイラの発展により容易に検出が可能です。 更に、左辺に変数がある方が可読性の観点からよいとされています。 このため今回はヨーダ記法の置換を試みました。

ヨーダ記法の置換で用いたのがlibclangです。 libclangはC, C++ソースコードを分析することができるライブラリの一つです

libclangとは

プログラミング言語処理系では実行形式を作成するために、字句解析や構文解析を行いAST(abstract syntax tree)を生成することが多いです。 ASTは実行形式を作る際に必要な情報を木構造で保管しており、作成したASTをもとにコンパイラは実行形式を作成します。 このためASTを解析することができれば、どのようにプログラムが解釈されたかがわかります。

代表的なC, C++コンパイラコンパイルの中間段階でASTをつくり、実行ファイルを生成しています。 C系コンパイラの一つであるclangによって生成されたASTを、簡単に解析することができるライブラリがlibclangです。 つまりlibclangを使うことでCのASTを解析することができ、ヨーダ記法の検出が可能になります。

libclangにはCインタフェースとPythonバインディングが存在します。*2 今回は

  1. clang17以降でないと二項演算子のインタフェースがないこと*3
  2. Pythonバインディングのバージョンが16であったこと*4

からCインタフェースを用いました。

対象、非対象のヨーダ記法例

以下が今回のコードで置換できる例です

if (42 == answer) { ... }
if (3.14 == satori && yutori == 3) { ... }

上記のヨーダ記法は以下のように変換されます

if (answer == 42) { ... }
if (satori == 3.14 && yutori == 3) { ... }

今回のコードでは変換されない例として以下のようなものがあります。 論理的に考えると左辺は定数ですが、今回は簡単な判定条件を用いているので定数とみなされません。*5

if ((int)57 == prime_number) { ... }
if (4 + 4 == happy) { ... }
if (NULL == gaxtu) { ... }

置換までの流れ

デバッグを容易にするために、変更箇所を含んだJSONを作成してJSONからCファイルの生成を行います。 すなわち以下の手順でヨーダ記法の検知を行います。

  1. ヨーダ記法をlibclangを用いて検出し、修正に必要な情報をJSON形式で保存する
  2. JSON形式のファイルを読み込み、ヨーダ記法である箇所の左右辺を入れ替え修正したC, C++ファイルを生成する
  3. 生成したC, C++ファイルをもとのC, C++ファイルと置き換える

速習libclang

libclangの機能の中で今回必要になる機能をざっと紹介します。

libclangの構造

ASTの操作に用いる代表的な構造体がIndex, TranslationUnit, Cursorです。 木構造のノードを指し示す構造体がCursor、一つのソースコード木構造として保持する構造体がTranslationUnit、複数のTranslationUnitを保存する構造体がIndexです。 *6 *7

以下の流れで、あるC系ファイルのrootノードを取得できます。

  1. 関数clang_createIndexを用いてIndexの作成
  2. 関数clang_parseTranslationUnitを用いてTranslationUnitを作りIndexに紐付け
  3. 関数clang_getTranslationUnitCursorを用いてTranslationUnitのrootノードを取得

以下はドキュメントにあるサンプルコードです。上記の一連の流れを表しています。 libclangを用いるためにclang-c/Index.hのインクルードが必要です。

#include <clang-c/Index.h>
#include <iostream>

int main()
{
    CXIndex index = clang_createIndex(0, 0); // Create index
    CXTranslationUnit unit = clang_parseTranslationUnit(
        index,
        "file.cpp", nullptr, 0,
        nullptr, 0,
        CXTranslationUnit_None); // Parse "file.cpp"

    if (unit == nullptr) {
        std::cerr << "Unable to parse translation unit. Quitting.\n";
        return 0;
    }
    CXCursor cursor = clang_getTranslationUnitCursor(unit); // Obtain a cursor at the root of the translation unit
}

ASTの探索方法

ASTの探索にはCursorを用います。 Cursorは子ノードの情報を保持するため、rootノードに対応するCursorを用いるとAST全体を探索することができます。 探索に用いるのが関数clang_visitChildrenです。

関数clang_visitChildrenに関数ポインタを渡すことでノードに対する操作を行うことができます。 関数ポインタとして渡す関数の戻り値にはCXChildVisit_Recurse, CXChildVisit_Continue, CXChildVisit_Breakの三種類の候補があり、探索方法に応じて以下のように指定します。

  1. CXChildVisit_Recurseは子ノードを含めた再帰的な探索(行きがけの順(深さ優先探索))
  2. CXChildVisit_Continueは自身の子ノードは探索しない
  3. CXChildVisit_Breakは探索をやめる

以下のコードはCXChildVisit_Recurseを返して再帰的な探索を行うことにより、深さ優先でノードの名前を出力する例です

clang_visitChildren(
    cursor, // Root cursor
    [](CXCursor current_cursor, CXCursor parent, CXClientData client_data) {
        CXString current_display_name = clang_getCursorDisplayName(current_cursor);
        // Allocate a CXString representing the name of the current cursor

        std::cout << "Visiting element " << clang_getCString(current_display_name) << "\n";
        // Print the char* value of current_display_name

        clang_disposeString(current_display_name);
        // Since clang_getCursorDisplayName allocates a new CXString, it must be freed. This applies
        // to all functions returning a CXString

        return CXChildVisit_Recurse;
    },      // CXCursorVisitor: a function pointer
    nullptr // client_data
);

ノードの判別

ノードの判別はCursorを介して行うことができます。 Cursorで判別に使うことができるのはCXCursorKindCXTypeです。*8

CXCursorKindは関数clang_getCursorKindで、CXTypeは関数clang_getCursorTypeで取得できます。

以下がCXCursorKindを用いて整数リテラルを判別する例です。

CXCursorKind kind = clang_getCursorKind(lhs_cursor);

switch (kind) {
    case CXCursor_IntegerLiteral:
      return true;
    default:
      return false;
}

ASTとソースコードとの対応

Cursorにはソースコードとの対応が記録されています。

ソースコードの該当箇所取得には関数clang_getCursorExtentを用います。 関数clang_getCursorExtent で取得できるのは、Cursorの最初の位置情報と最後の位置情報を持つCXSourceRangeです。 ここで得られる最初と最後の位置とは、子ノード全体を含んだ範囲の最初と最後の位置になります。

CXSourceRangeの取得後、最初と最後の位置情報であるCXSourceLocation取得することができます。

以下がCursorの最初と最後の行と列を取得する例です。

CXSourceRange range = clang_getCursorExtent(cursor);
CXSourceLocation start = clang_getRangeStart(range);
CXSourceLocation end = clang_getRangeEnd(range);
unsigned start_line, start_column;
unsigned end_line, end_column;
clang_getExpansionLocation(start, nullptr, &start_line, &start_column,
                           nullptr);
clang_getExpansionLocation(end, nullptr, &end_line, &end_column, nullptr);

ヨーダ記法の検知

ここからは前述した知識を使いながら、実際にヨーダ記法を検知するプログラムを書いていきます。 ヨーダ記法を検知した場合、JSONファイルにその位置情報を出力します。

検索条件

ここでは以下の条件を満たすヨーダ記法を検出します。

JSONフォーマット

出力には以下のFileInformationsをトップレベルにもつJSONフォーマットを利用します。

using FileInformations = std::vector<FileInformation>;
struct FileInformation {
    std::string filename;
    std::vector<Yoda> yoda;
};
struct Yoda {
    unsigned lhs_linestart;
    unsigned lhs_lineend;
    unsigned lhs_columnstart;
    unsigned lhs_columnend;
    
    std::string op;
    
    unsigned rhs_linestart;
    unsigned rhs_lineend;
    unsigned rhs_columnstart;
    unsigned rhs_columnend;
}

ソースコード

検知処理(C++)

以下が検知処理全体のC++ソースです。検知のコア部分は関数detect_yoda_conditionsで行っています。 *9 *10

#include <cassert>
#include <clang-c/Index.h>
#include <expected>
#include <iostream>
#include <optional>
#include <string>
#include <vector>

static CXFile s_sourcefile;

void print_cursor(const CXCursor& cursor, const std::string prefix)
{
    const CXSourceRange range = clang_getCursorExtent(cursor);
    const CXSourceLocation start = clang_getRangeStart(range);
    const CXSourceLocation end = clang_getRangeEnd(range);

    unsigned start_line, start_column;
    unsigned end_line, end_column;
    clang_getExpansionLocation(start, nullptr, &start_line, &start_column,
                               nullptr);
    clang_getExpansionLocation(end, nullptr, &end_line, &end_column, nullptr);

    std::cout << "                \"" << prefix << "linestart\": " << start_line
              << "," << std::endl;
    std::cout << "                \"" << prefix << "lineend\": " << end_line
              << "," << std::endl;
    std::cout << "                \"" << prefix
              << "columnstart\": " << start_column << "," << std::endl;
    std::cout << "                \"" << prefix << "columnend\": " << end_column
              << std::endl;
    return;
}

void print_op_cursor(const CXCursor& cursor)
{
    std::cout << "                \"op\": \""
              << clang_getCString(clang_getCursorSpelling(cursor)) << "\""
              << std::endl;
    return;
}

void print_cursors(const CXCursor& lhs, const CXCursor& op,
                  const CXCursor& rhs)
{
    static bool firstflag = true;
    CXFile file;
    clang_getExpansionLocation(clang_getCursorLocation(op), &file, nullptr,
                               nullptr, nullptr);
    if (file != s_sourcefile) {
        return;
    }

    if (!firstflag) {
        std::cout << "            ," << std::endl;
    }
    firstflag = false;
    std::cout << "            {" << std::endl;
    print_cursor(lhs, "lhs_");
    std::cout << "," << std::endl;
    print_op_cursor(op);
    std::cout << "," << std::endl;
    print_cursor(rhs, "rhs_");
    std::cout << "            }" << std::endl;
}

void print_json_header(const std::string& sourcefilename)
{
    std::cout << "[" << std::endl;
    std::cout << "    {" << std::endl;
    std::cout << "        \"filename\": \"" << sourcefilename << "\"," << std::endl;
    std::cout << "        \"yoda\": [" << std::endl;
}

void print_json_footer()
{
    std::cout << "        ]" << std::endl;
    std::cout << "    }" << std::endl;
    std::cout << "]" << std::endl;
}

bool check_lhs(const CXCursor lhs_cursor)
{
    const CXCursorKind kind = clang_getCursorKind(lhs_cursor);

    switch (kind) {
    case CXCursor_IntegerLiteral:
    case CXCursor_FloatingLiteral:
        return true;
    default:
        return false;
    }
}

std::optional<std::vector<CXCursor>> detect_yoda_conditions(CXCursor cursor)
{
    if (clang_getCursorKind(cursor) != CXCursor_BinaryOperator) {
        return std::nullopt;
    }
    const CX_BinaryOperatorKind op = clang_Cursor_getBinaryOpcode(cursor);
    if (op != CX_BO_EQ && op != CX_BO_NE) {
        return std::nullopt;
    }
    std::vector<CXCursor> cursors;
    clang_visitChildren(
        cursor,
        [](CXCursor c, CXCursor parent, CXClientData client_data) {
            std::vector<CXCursor>* cursors = (std::vector<CXCursor>*)client_data;
            cursors->push_back(c);
            return CXChildVisit_Continue;
        },
        &cursors);
    // because binary operator has two children, lhs and rhs
    assert(cursors.size() == 2);
    const CXCursor& lhs_cursor = cursors[0];
    if (!check_lhs(lhs_cursor)) {
        return std::nullopt;
    }
    return cursors;
}

int main(int argc, char** argv)
{
    std::string sourcefilename;
    if (argc < 2) {
        std::cerr << "Usage: " << argv[0] << " <source_file.cpp>" << std::endl;
        return 1;
    } else {
        sourcefilename = argv[1];
    }
    const std::string dump_json = "build/" + sourcefilename + ".json";

    CXIndex index = clang_createIndex(0, 0);
    CXTranslationUnit tu =
        clang_parseTranslationUnit(index, sourcefilename.c_str(), nullptr, 0,
                                   nullptr, 0, CXTranslationUnit_None);

    if (tu == nullptr) {
        std::cerr << "Error parsing translation unit." << std::endl;
        return 1;
    }

    // stdout -> dump_json
    s_sourcefile = clang_getFile(tu, sourcefilename.c_str());
    const CXCursor rootCursor = clang_getTranslationUnitCursor(tu);

    std::freopen(dump_json.c_str(), "w", stdout);
    print_json_header(sourcefilename);
    clang_visitChildren(
        rootCursor,
        [](CXCursor c, CXCursor parent, CXClientData client_data) {
            std::vector<CXCursor> cursors;
            if (auto maybe_cursors = detect_yoda_conditions(c)) {
                cursors = *maybe_cursors;
                print_cursors(cursors[0], c, cursors[1]);
            }
            return CXChildVisit_Recurse;
        },
        nullptr);
    print_json_footer();
    std::fclose(stdout);

    clang_disposeTranslationUnit(tu);
    clang_disposeIndex(index);
    return 0;
}

C++ファイル生成スクリプト(Python)

PythonJSONを読み込み置換後のファイルを生成します。

import json
import sys

def read_source_file(filename):
    """ソースファイルを読み込む"""
    with open(filename, 'r', encoding='utf-8') as file:
        return file.readlines()

def write_source_file(filename, lines):
    """修正されたソースコードを書き戻す"""
    with open(filename, 'w', encoding='utf-8') as file:
        file.writelines(lines)

def load_yoda_conditions(json_file):
    """JSONからヨーダ記法条件情報を取得"""
    with open(json_file, 'r', encoding='utf-8') as file:
        data = json.load(file)
    # 結果を展開
    return data[0]["yoda"]

def replace_yoda_conditions(lines, conditions):
    """行情報に基づいてヨーダ記法を置換"""
    for condition in conditions:
        lhs_start_line = condition["lhs_linestart"] - 1
        lhs_end_line = condition["lhs_lineend"]-1
        lhs_start_col = condition["lhs_columnstart"] - 1
        lhs_end_col = condition["lhs_columnend"]-1
        # get lhs
        lhs = ""
        for i in range(lhs_start_line, lhs_end_line+1):
            if (i >= len(lines)):
                print(len(lines), lhs_start_line, lhs_end_line)
                raise (IndexError("Error: lhs out of range"))
            if i == lhs_start_line and i == lhs_end_line:
                print(len(lines[i]), lhs_start_col, lhs_end_col)
                lhs += lines[i][lhs_start_col:lhs_end_col]
            elif i == lhs_start_line:
                lhs += lines[i][lhs_start_col:]
            elif i == lhs_end_line:
                lhs += lines[i][:lhs_end_col]
            else:
                lhs += lines[i]
        # get op
        op = condition["op"]
        # get rhs
        rhs_start_line = condition["rhs_linestart"] - 1
        rhs_end_line = condition["rhs_lineend"]-1
        rhs_start_col = condition["rhs_columnstart"] - 1
        rhs_end_col = condition["rhs_columnend"]-1
        rhs = ""
        for i in range(rhs_start_line, rhs_end_line+1):
            if i == rhs_start_line and i == rhs_end_line:
                rhs += lines[i][rhs_start_col:rhs_end_col]
            elif i == rhs_start_line:
                rhs += lines[i][rhs_start_col:]
            elif i == rhs_end_line:
                rhs += lines[i][:rhs_end_col]
            else:
                rhs += lines[i]

        new_line = rhs + "" + op + "" + lhs
        print(f"Replace: {lhs} {op} {rhs} -> {new_line}")
        for i in range(lhs_start_line, rhs_end_line+1):
            if i == lhs_start_line:
                lines[i] = lines[i][:lhs_start_col] + new_line + \
                    lines[rhs_end_line][rhs_end_col:]
            else:
                lines[i] = ""


def main(source_file, json_file, output_file=None):
    # ソースファイル読み込み
    lines = read_source_file(source_file)

    # JSONからヨーダ記法情報を取得
    conditions = load_yoda_conditions(json_file)

    # 条件式置換処理
    replace_yoda_conditions(lines, conditions)

    # 修正後のソースを書き戻し
    if output_file:
        write_source_file(output_file, lines)
    else:
        write_source_file(source_file, lines)
    print(f"Yoda conditions replaced successfully in {source_file}")

if __name__ == "__main__":
    if len(sys.argv) < 3:
        print(f"Usage: {sys.argv[0]} <source_file> <json_file>")
        sys.exit(1)
    elif len(sys.argv) == 3:
        main(sys.argv[1], sys.argv[2])
    else:
        main(sys.argv[1], sys.argv[2], sys.argv[3])

自動化用Makefile

自動化のためのMakefileです。 上記のC++ファイルをanalyser.cppPythonファイルをgenerate_non_yoda.pyで保存し、Makefileと同一の階層に置くと実行できるはずです。

top: run

CC = clang-20
CXX = clang++-20

# use libclang
CXXFLAGS += $(shell llvm-config-20 --cxxflags)
LDFLAGS += $(shell llvm-config-20 --ldflags --libs --system-libs) -lclang

# get all c and cpp files
ANALYSIS_C = $(filter-out %.gen.c, $(shell find . -name "*.c" -not -path "*/third-party/*" -not -path "*/build/*" -not -path "./third_party/*"))
ANALYSIS_CPP = $(filter-out %.gen.cpp, $(shell find . -name "*.cpp" -not -path "*/third-party/*" -not -path "*/build/*" -not -path "./third_party/*"))

# target files
ANALYSIS_JSON = $(addprefix build/, $(ANALYSIS_CPP:.cpp=.cpp.json) $(ANALYSIS_C:.c=.c.json))
ANALYSIS_OBJS = $(addprefix build/, $(ANALYSIS_CPP:.cpp=.o) $(ANALYSIS_C:.c=.o))
ANALYSIS_GEN_C:=$(addprefix build/, $(ANALYSIS_CPP:.cpp=.gen.cpp) $(ANALYSIS_C:.c=.gen.c))

# analysis source
ANALYSER_SRC = analyser.cpp
GENERATER = generate_non_yoda.py

re: clean run

run: $(ANALYSIS_GEN_C) $(ANALYSIS_JSON) build/analysis

build/%.gen.cpp: %.cpp build/%.cpp.json $(GENERATER)
   -mkdir -p $(dir $@)
  python3 $(GENERATER) $*.cpp build/$*.cpp.json build/$*.gen.cpp
  mv build/$*.gen.cpp $*.cpp

build/%.gen.c: build/%.c.json $(GENERATER)
   -mkdir -p $(dir $@)
  python3 $(GENERATER) $*.c build/$*.c.json build/$*.gen.c
  mv build/$*.gen.c $*.c

build/%.cpp.json: %.cpp build/analysis
   -mkdir -p $(dir $@)
  ./build/analysis $<

build/%.c.json: %.c build/analysis
   -mkdir -p $(dir $@)
  ./build/analysis $<

build/analysis.o: $(ANALYSER_SRC) Makefile
   -mkdir -p build
  $(CXX) $(CXXFLAGS) -c $< -o $@

build/analysis: build/analysis.o
   -mkdir -p build
  $(CXX) $(CXXFLAGS) $^ $(LDFLAGS) -o $@

clean:
  rm -rf build

.PHONY: re run clean top

おわりに

libclangを用いてぱっとめんどくさい作業が自動化できました。 ほかにもいろいろできそうなので、興味持った方はぜひ使ってみてください。

インターンものこり一ヶ月半となりました! 少ない時間、楽しんでいきたいです!!

参考

Libclang tutorial — Clang 21.0.0git documentation
https://clang.llvm.org/doxygen/modules.html

*1:決して記法として悪いものではなく、現在でも"if ( auto* p = get_some_object() )"のように用いることがある

*2:ほかにもluaバインディングがあるみたい。書いてから知りました。全くメンテされてなさそうだけど

*3:https://ifritjp.github.io/documents/libclang/operator/

*4:https://libclang.readthedocs.io/en/latest/# 2021年からlatestの更新がなさそう

*5:以下のものを判定条件に入れるのはそこまで難しくないはずです。ぜひ。

*6:Cコンパイラは分割コンパイルによるファイル分割が可能なので、Indexが複数のTranslationUnitを持つという構造にしている。

*7:生成されるTranslationUnitの例は"clang -Xclang -ast-dump -fsyntax-only sample.c"などで見ることができる

*8:CursorをTokenに変換して文字列で判別する力技も可能

*9:もともと書き捨てのコード予定だった(人に見せるつもりがなかった)のとGPTが一瞬で出してくれたので、JSON出力はライブラリを使わずに手書きしています

*10:オブジェクトの破棄などは特に定義していません。OSの後片付けにすべてまかせています。ちゃんとしたい場合は、Cursorやファイル操作に関する終了操作が必要です。