マラソン系コンテストでソースコードを分割して書く方法のメモ(C++)

はじめに

こんにちは。競技プログラミングのコンテストに参加しているyunixです。

ラソン系のコンテストに参加していると実装が複雑になりがちで、ソースコードが数千行くらいになることもあります。 一方でAtCoderをはじめとしたコンテストでは提出を一つのソースコードにしなければいけません。

いきおい一つのファイルにたくさんのクラスや関数を定義することになるのですが、

  • 実装箇所を探すときにファイルの上下移動が多く発生してややこしい
  • 使わなくなったんだけれど参考までに残しておきたい箇所が放置され消していいのかわからなくなる

などの点で認知負荷が高くなっている気がしてストレスを感じていました*1。 マラソンでは気力が切れたら負けなので、コーディングの際のストレスは極力減らしたいです。

そこで最近は

  • 開発時にはソースファイルを分割して記述
    • ローカルでビルドするときにはg++ main.C -I . などとしてビルドできる
  • 提出時にはPythonのコマンドを実行してファイルを結合して1つのファイルにする

というやり方を採用することにしました。 いまいちこのテーマで記事を見かけなかったので、やり方に関するメモを残そうと思います。 個人的には満足しているのですが、ベストな方法だとは思っていません。他の記事や良いやり方がありましたらコメントに書いていただければ嬉しいです。

仕組み

ソースコードを一定のルールに従って記述して、Pythonスクリプトソースコードを解析して結合したファイルを作ります。 説明を長々と書くよりも実際に見てもらった方が早い気がするので、サンプルコードを以下のリポジトリに用意しました: github.com

考え方

基本的な考え方は以下の通りです:

  • main.cppに展開したいソースファイルを順番にincludeしていく*2
    • "module/headers.cpp"などが展開したいファイルです
    • pythonスクリプトはincludeしたファイルを探して、"include xxx"の記述と置き換えます
#include "modules/headers.cpp"
#include "modules/piyo.cpp"
#include "modules/hoge.cpp"

int main()
{
    Hoge hoge = Hoge();
    hoge.say_hoge();
    hoge.say_piyo();

    return 0;
}
  • 展開されるファイル(modules/piyo.cppなど)は置き換えたい場所を/*start*/よりも下の行に書く
    • 下のファイルの場合だと"class Piyo"から末尾(#endifの手前)までをmain.cppに展開します
    • これはこのファイル用にも各種ヘッダをincludeする必要がありますが、Pythonでファイルを結合するときに結合先のファイルに"include modules/headers.cpp"などを入れたくない、というような事情があります
#ifndef PIYO_HPP
#define PIYO_HPP
#include "modules/headers.cpp"

/*start*/

class Piyo
{
public:
    Piyo(){};
    void say_piyo();
};

void Piyo::say_piyo()
{
    cout << "piyo" << endl;
}
#endif
import glob


def read_source_file(file_name: str) -> str:
    with open(file_name, "r") as f:
        return f.read()


def extract_content(file_path: str) -> str:
    print(file_path)
    with open(file_path, "r") as f:
        txt = f.read()

    return txt.split("/*start*/")[1].split("#endif")[0]


source_file = read_source_file("main.cpp")

l = glob.glob("modules/*.hpp")
for file_path in l:
    source_file = source_file.replace(f'#include "{file_path}"', extract_content(file_path))

l = glob.glob("modules/lib/*.hpp")
for file_path in l:
    source_file = source_file.replace(f'#include "{file_path}"', extract_content(file_path))


with open("combined.cpp", "w") as f:
    f.write(source_file)
  • 結合したファイルは以下のような形になります:
/* modules/headers.cppの部分 */
#include <iostream>
#include <string>
#include <vector>
#include <tuple>
#include <chrono>
#include <algorithm>
#include <random>
#include <map>
#include <set>
#include <queue>
#include <random>
#include <chrono>
#include <cmath>
#include <climits>
#include <bitset>
#include <time.h>

using namespace std;



/* modules/piyo.cppの部分 */

class Piyo
{
public:
    Piyo(){};
    void say_piyo();
};

void Piyo::say_piyo()
{
    cout << "piyo" << endl;
}



/* modules/hoge.cppの部分 */

class Hoge
{
public:
    Hoge(){};
    void say_hoge();
    void say_piyo();
};

void Hoge::say_hoge()
{
    cout << "hoge" << endl;
}

void Hoge::say_piyo()
{
    Piyo piyo = Piyo();
    piyo.say_piyo();
}


int main()
{
    Hoge hoge = Hoge();
    hoge.say_hoge();
    hoge.say_piyo();

    return 0;
}

注意点

機械的にincludeの箇所を探して記述を置き換えているだけなので、main.cppでファイルを展開する順序を考えておかなければいけません。 例えばサンプルのリポジトリの例で言うと、Hogeクラスは内部でPiyoクラスを使っているので、

#include "modules/hoge.cpp"
#include "modules/piyo.cpp"

としてしまうとコンパイルできなくなってしまいます。

この辺り賢く解析したら解決できる気はするのですが、一人で開発するコンテスト用途だったら実用上十分な気がするのでまあいいかと思っています。

最後に

同じような仕組みで他の言語でもできると思います。やっていることはとても原始的ですが、コンテストの際のストレスを結構減らせると思います。

*1:個人差はあると思います。chokudaiさんはこの前のあーだこーだーで適切にクラス分け等がされていれば一つのファイルでもそんなに苦じゃないと言っていました。

*2:includeについてはAPG4bに説明があります: https://atcoder.jp/contests/apg4b/tasks/APG4b_af