今引き継いだレガシー C++ コードベース、これからどうする?
(gaultier.github.io)- 大きく複雑なレガシー C++ コードベースを引き継いだなら、目標は「きれいなコード」ではなく、セキュリティ、開発者体験、正確性、性能を許容可能な状態まで引き上げること
- 出発点は、組織の合意と時間制約を確保し、サポート対象プラットフォームを README に書いたうえで、ローカルビルドとテストが安定して通るようにすること
- ビルド・テスト速度の改善は、依存関係のテスト除去、不要なターゲット整理、
moldのような高速リンカの試行といった難易度の低い改善から、計測しながら進めるべき - 未使用コードと未サポートプラットフォーム向けコードを減らしたうえで、
clang-tidy、cppcheck、clang-format、-fsanitize=address,undefined、CI による自動検証ループを作るべき - リライトや最新 C++ 標準の導入は目的ではなく手段であり、メモリ安全な言語への書き直しも強い理由があるときだけ検討に値する
最初はコードより人と手順を整理する
- レガシー C++ コードベースの改善は、退勤後に一人で押し進める作業でも、長期的な「死の行進」でもなく、持続可能なソフトウェアエンジニアリング作業であるべき
- 上司、同僚、非技術メンバーに、問題、解決策、時間制約を平易な言葉で説明する必要がある
- 新入社員がローカルビルドと最初の貢献までに 3 週間かかったなら、それを数分まで短縮する目標を提案できる
- 簡単なファジング設定で数秒のうちにアプリが 253 回クラッシュするなら、本番環境でのリスクを説明する根拠になる
- 8 年間サポートされていない FreeBSD 9 のビルドサーバー 1 台にデプロイが依存しているなら、そのサーバー障害でデプロイが止まる状態である
- 業界標準のリンタが、本番バグの原因になった未定義動作を即座に検出するなら、変更のたびに実行する十分な理由になる
- 手動でコピーして修正した暗号ライブラリのせいで脆弱性の影響が分からないなら、依存関係の整理と自動通知が必要
- 避けるべきアプローチも明確
- テストがないのに、最新 C++ 標準へ 2 週間かけて全面アップグレードする
- 数か月にわたり別ブランチで大規模変更を作り、いつかマージされると期待する
- 数週間で終わるという前提で、最初から全面リライトを始める
- 何をいつまでやるのか分からないまま「コードベース改善」を始める
安全な改善の進め方
- すべての変更は小さく段階的であるべきで、変更前後でアプリが動作し、テストとリンタが通る必要がある
- 緊急バグ修正はこれまで通り可能であるべきで、改善作業のせいで妨げられてはならない
- 各変更は測定可能な改善であるべきで、非専門家にも説明または実演できる必要がある
- 優先順位や予算の問題で作業全体が中断しても、開始前より測定可能な純利益が残っているべき
サポート対象プラットフォームを README に明記する
- README には、公式にサポートする
<architecture>-<operating-system>の組を記載すべき- 例:
x86_64-linux,aarch64-darwin
- 例:
- この一覧は、すべてのサポート対象プラットフォームでビルドできるか確認する基準になり、後で非サポートプラットフォーム向けコードを削除する根拠にもなる
- 必要なら ARMv6、ARMv7 のようなアーキテクチャバージョンも明記できる
- サポート対象プラットフォーム一覧は、次の質問に答えられるようにしてくれる
- 浮動小数点、SIMD、SHA256 ハードウェアサポートに依存できるか
- 32 ビット対応が必要か
- ビッグエンディアンのプラットフォームで動作するか
charが 7 ビットである可能性を考慮すべきか
- 開発者のワークステーションもこの一覧に必ず含めるべき
まずローカルビルドとテストを安定させる
- 成功している製品の中核 C++ コードベースでさえ安定してコンパイルできないことは多く、目標は「たまに通るビルド」ではなく、すべてのサポート対象プラットフォームで一貫して通るビルドである
- 最も望ましい状態は、開発者マシンで直接ビルドして実行できること
- プロジェクトが大きすぎて RAM が足りないなら、大きなサーバーをビルド用に借りてもよい
io_uringのようなプラットフォーム専用 API が必要なら、shim を実装するか、ワークステーション上の仮想マシン内でビルドできる- それでも、直接のローカルビルドが最良の選択
- テストがないなら、コード変更の前にまずテストを書くべき
- 実際に動いているプログラムの入力と出力をキャプチャしてエンドツーエンドテストを作る方法が、最も簡単な出発点
- こうしたテストは既存動作が正しいことまでは保証しないが、変更時のリグレッションを減らしてくれる
- テストスイートがあって一部が失敗するなら、ひとまず無効化し、テスト全体に数時間かかっても通る状態を先に作る
ビルドとテストの方法を文書化する
- README には、アプリをビルドしてテストする方法を書くべき
- 理想形はビルドコマンド 1 つとテストコマンド 1 つ
- 最初は手順が複雑なら、
build.sh、test.shでコマンドを包み、複雑さを隠せる - 目標は、C++ の専門家でない人でも質問なしにビルドとテストを実行できる状態
- プロジェクト構造やアーキテクチャの文書化は、後で不要コードを削除してから行うほうがよい
難易度の低いやり方でビルドとテスト時間を短縮する
- ビルドシステムを作り直したり、英雄的な最適化をしたりせず、まずは難易度の低い改善から計測すべき
- まず確認する項目
- 依存関係のテストを毎回ビルド・実行していないか確認する
unittest++を CMake サブプロジェクトとして使ったとき、テストフレームワーク自身のテストまで毎回ビルド・実行されていた事例がある
- 依存関係のサンプルプログラムをビルド・実行していないか確認する
mbedtlsも CMake 変数でサンプルビルドを無効化できた
- 自分のプロジェクトが他プロジェクトのサブプロジェクトとして組み込まれたとき、テストやサンプルをデフォルトでビルドしていないか確認する
MYPROJECT_TESTのようなビルド変数はデフォルトで無効にし、直接開発するときだけ有効にする方式を推奨
- サードパーティ依存関係全体をビルドしているが、実際には一部しか使っていないか確認する
mbedtlsは不要な部分を無効化するコンパイル時フラグを多く提供している
- ターゲット依存関係が誤っていて、小さな変更でも全体が再ビルドされていないか確認する
- 多くのビルドシステムは依存関係グラフを出力できる
moldのような高速リンカを試す- 可能ならコンパイラも比較する
- あるプロジェクトでは clang が gcc より 2 倍速かったが、別のプロジェクトでは差がなかった
- 依存関係のテストを毎回ビルド・実行していないか確認する
- 追加で試せるが、効果が小さいかマイナスの可能性もある項目
- LTO の無効化/有効化/thin
- デバッグ情報の分離
- Make と Ninja の比較
- ファイルシステムの種類と設定
- ビルドに時間がかかりすぎると、コード修正そのものが現実的でなくなる
不要なコードを削除する
- コードベースの 30% 以上が完全に死んだコードだった例もあり、こうしたコードはコンパイルやリファクタリングのコストを継続的に増やす
- 削除方法
- コンパイラの
-Wunused-xxx警告を使う- 例:
-Wunused-function - 多くは削除して再ビルド・再テストで終わるが、まれに誤った関数が呼ばれているバグの症状であることもあるため、完全自動化には注意が必要
- 例:
cppcheckのようなリンタで未使用の関数やクラスフィールドを見つける- 継承や仮想関数では偽陽性が多い可能性があるが、コンパイラが見つけられない未使用要素を見つけられる
- リンカに各関数を別セクションへ置かせ、未使用セクション除去を出力させる方法もあるが、標準ライブラリ関数のノイズが多く実用的でない場合がある
- 生成されたアセンブリとソースコードを比較する方法は、仮想関数にはあまり向かない
- コンパイラの
- サポート対象プラットフォーム一覧を活用して非サポートプラットフォーム向けコードを削除する
- 実際には FreeBSD でしか動いていないプロジェクトに残っていた古い Solaris 対応コード
- 実際のプラットフォームには常に乱数生成器があるのに、自前の乱数生成器を持っているコード
- 現代の Linux と macOS でしか動かないのに、POSIX 2001 非対応に備えたコード
- ビッグエンディアン CPU かどうかを調べてバイトスワップするコード
- 数年前に入ったが実際の機能へつながらなかったコード
- 大量のコード削除 PR は、ビルド時間の短縮と保守コストの削減を同時に示せる
リンタ、フォーマッタ、サニタイザを導入する
- リンタはルールを過剰に有効化せず、基本的なものをいくつか開発ライフサイクルに組み込むべき
clang-tidy、cppcheckは有用だが、遅くノイズが多いこともある- リンタがまったくない状態は選択肢ではなく、最初の実行だけでもコンパイラ警告では見つからなかった実際の問題を多く発見できる
- コードフォーマットは適切なタイミングで一度に適用すべき
- 悲惨なマージ衝突を避けるため、アクティブなブランチがない時期を待つ必要がある
- スタイル論争に時間を使わず、
clang-formatのようなツールでコードベース全体を例外なく整形する - 設定も一緒にコミットする
- サニタイザは、本番に影響しうる見つけにくいバグを捕まえるために必要
- デフォルトでは
-fsanitize=address,undefinedを推奨 - たいてい偽陽性がないので、検出されたら修正すべき
- テストもサニタイザを有効にした状態で実行して問題を見つける
- 性能予算が許すなら、一部サニタイザを有効にした本番実行も検討できる
- デフォルトでは
- デプロイ用コンパイラがサニタイザをサポートしていなくても、開発とテストでは clang のようなコンパイラを使える
- サニタイザを有効にすると、長く潜んでいたバグやメモリリークが表面化することがあり、修正には多くの作業やリファクタリングが必要になる場合がある
- 可能ならテスト時にサードパーティ依存関係もサニタイザ付きでコンパイルし、依存関係内部の問題も見つけるほうがよい
CI で自動化する
- CI は、ここまでに設定したリンタ、フォーマット、テストなどをクリーンな環境で自動化する
- 変更のたびに本番バイナリを生成できる必要がある
- 多くの CI システムは複数プラットフォームのマトリクスをサポートするため、README のサポート対象プラットフォーム一覧が実際にビルドされるか確認できる
- 一般的なパイプラインは
make all test lint fmtのように単純でよい - リンタとサニタイザが報告した問題はパイプライン失敗につながるべきで、そうでなければ誰も修正しない
コードは段階的に単純化する
- 安定したビルド・テスト・検証ループができた後なら、コードを少しずつ単純化できる
- 複雑なクラスが実際にはポインタを割り当てて null かどうかを確認するだけの役割なら、実質的に boolean で置き換えられる例もある
- この段階は、単純化するほどさらに単純化できる箇所が見えてくるため、時間制約を決めにくく、保守的に判断すべき
- 目標はセキュリティ、正確性、性能のような具体的価値であるべきで、「クリーンコード」のような主観的基準は避けるべき
- C++ 標準アップグレードは目的ではなく手段
- たとえば、手動でイテレータを進めるコードを
for (auto x : items)ループへ置き換える助けになることはある - 必要なのが
std::clampひとつだけなら、自前で書くほうがよいかもしれない
- たとえば、手動でイテレータを進めるコードを
メモリ安全な言語への書き直しは別判断が必要
- 一部をメモリ安全な言語で書き直すという選択肢はあるが、注意点が多い
- 強い理由があるときだけ進めるべき
依存関係管理はソースビルド中心を好む
- C++ には一貫した依存関係管理がなく、多くのプロジェクトがシステムパッケージマネージャを使っている
- システムパッケージ依存方式の問題
- インストール手順が OS、ディストリビューション、ディストリビューションのバージョンに依存する
- Ubuntu 20.04 から 22.04 へ移るとき、パッケージバージョンが変わり、100 個の依存関係を同時にアップグレードしなければならない状況が起こりうる
- パッケージにないサードパーティ依存関係は、結局ソースからビルドするしかない
- パッケージが望むフラグでビルドされていない
- サニタイザ、LTO、
-march、デバッグ情報、フレームポインタ、C++ ABI の違いなどが問題になる可能性がある
- サニタイザ、LTO、
- 現在使っている正確なバージョンのソースを、監査・開発・デバッグ時に見づらい
- 依存関係へパッチを当てて再ビルドするのが難しい
- macOS、Ubuntu、FreeBSD 間でまったく同じパッケージバージョンを使いにくい
- 自動化された BOM 生成が難しくなる
- 静的/動的ライブラリ形態など、必要なパッケージ変種が存在しないことがある
- Conan、vcpkg のような C++ パッケージマネージャも改善にはなりうるが、制約がある
- 外部依存のせいで CI が複雑かつ遅くなることがある
- すべてのパッケージバージョンが用意されているとは限らない
- Conan の mbedtls では
2.16.12から2.23.0へ飛んでいる例がある
- Conan の mbedtls では
- 必要な OS やアーキテクチャをサポートしていない場合がある
- 推奨される方法は、git submodule で依存関係を取り込み、ソースからコンパイルすること
- 単純
- 手動ベンダリングより git の履歴や diff を活用できる
- 依存関係のバージョンをコミット単位で正確に把握できる
- 単一依存関係のアップグレードは
git checkoutで可能 - すべてのプラットフォームで動作する
- 依存関係ごとにコンパイルフラグやコンパイラを選べる
- C++ 経験がなくても、開発者は git を知っている
- 再帰的に動作する
- CMake の
add_subdirectoryやgit submodule foreach makeで各サブモジュールをビルドできる - サブモジュールが難しいなら、Neovim のようにスクリプト 1 本で依存関係を取得してビルドする方式も可能
- 依存関係グラフが非常に大きいなら、Buck2 のようなハイブリッドなローカル・リモートビルドシステムとビルド成果物の再利用が必要になることもある
- Go や Rust のようなコンパイル言語のパッケージ管理は、ソースからコンパイルするアプローチを採っている
読者提案で補強された点
- テストはさらに強調すべきで、C++ のテストスイートはサニタイザ下で実行しないと、誤った安心感に陥る
- vcpkg は、要件とクロスコンパイルを満たせるなら git submodule より良い選択肢になりうる
- Nix は C++ 依存関係マネージャとして使えるが、複雑さと遅さが問題
- 1 年に 1 つしかバグを直さないなら大規模リファクタリング投資は判断の問題だが、死んだコードの削除とサニタイザは、修正頻度が低くても価値がある
- コード削除は、静的解析で呼ばれていないことを確認できる場合に集中すべきで、疑わしいなら削除しないほうが安全
- 仮想メソッドは実行時に呼び出し先が決まるため、静的解析だけで削除するのは難しい
- 営業、プロダクトマネージャ、ユーザーに機能・プラットフォームの利用有無を聞く 15 分の会話が、技術作業を大きく減らすこともある
- LLM にコードを入れて質問する方法は、法的に安全で可能ならローカルで実行し、結果は慎重に受け止めるべき
- コード解析ツールで図やクラス関係を生成し、全体構造を把握するというアイデアもある
- ソース管理システムがないなら、0 段階目はコードを VCS に入れること
- CI を 1 段階目と見るべきだという意見もあり、ローカルのほうが速くても妥当な見方ではある
- ほとんど使われないプラットフォームを削除すると、組み合わせの複雑さが減り、大きな単純化につながる場合がある
- 完全に再現可能なビルドは一般的な C++ コードベースでは現実的でないかもしれないが、信頼できるビルドは現実的
- コード全体をフォーマットするコミットは、git が特定コミットを blame で無視するよう設定して、履歴追跡の負担を減らせる
- VCS 履歴統計で、変更が多い箇所や一緒に変更されるファイルを見つけるアイデアもある
- このアプローチは C++ だけでなく他のレガシーコードベースにも適用でき、サニタイザのような C++ 特有の部分を除けばよい
Working effectively with Legacy Codeは関連する助言を含む本として推奨される- 実際の価値に集中すべきだが、大規模な C++ コードベースをセキュリティ観点で見ると多くの脆弱性が見つかる可能性があり、これは財務的利益ではなくリスク低減として価値がある
1件のコメント
Hacker News のコメント
良い助言もあれば、やや議論を呼ぶ助言もあります。複数の巨大な C++ プロジェクトを引き継いだ経験からすると、初期に確実に大きな効果がある作業はいくつかあります。
まずは再現可能なビルドを作り、Docker や好みのパッケージングツールでビルド環境を包み、ツールと依存関係を明示的かつ再現可能にすべきです。
-Wallで警告なしにビルドできるようにすると、悪いコード、未定義動作、バグが表面化し、その後自分が怪しいコードを入れたときに即座に警告を確認できます。初期に
valgrindのようなツールで読み書きのエラーを確認するのも安定性の面では手軽な成果であり、全体構造を理解するまではリファクタリングを局所的に保つほうが安全です。完全性ハッシュなしで変化するイメージタグを指していたり、
apt-getを呼び出したりする Dockerfile はよくあり、どちらも微妙に異なる状態に行き着く余地を大きく残します。学ぶのはより難しいものの、Bazel や Nix のような解決策のほうがはるかに良い基盤になります。
ステップ 2 はすべてのテストをサニタイザ配下で実行して失敗する項目に印を付け、ステップ 3 はそのサニタイザの失敗をすべて直すこと、ステップ 4 が残りの作業に当たります。
コンテナは良いものですが、問題がないふりをするための方法としてはひどいものです。
依存関係をすべてまとめ、
/usr/libに何十億ものものが正しいバージョンで入っていないと動かない状態は避けるべきです。-Wallできれいにビルドするのはよいですが、本番ビルドに-Wall -Werrorを入れることには強く反対します。コンパイラ警告の一部は意見に近く、新しいコンパイラバージョンが新しい警告を追加すると、以前はきれいだったコードが突然拒否される可能性があります。
-Werrorが必要なら、デバッグビルドだけで使うほうがよいでしょう。2番と3番の順序は入れ替えます。CI、リンティング、自動フォーマットなどを先に整えることが、何かを剥がし取るより優先です。
まだ何を削除すべきか、削除したときにどんな結果になるか分からないからです。
リンターと静的解析ツールは、プログラムのどの部分に作業が必要かについて多くの洞察を与えてくれます。
今日、静的解析ツールが示す箇所は、後で STL の概念を再実装した関数、クラス、ファイル全体を削除できる場所であることがよくあります。
自作のイテレータライブラリ、自作のスマートポインタ、C 文字列関数の使用などを、STL アルゴリズム、本物のスマートポインタ、C++ 文字列クラスに置き換えられます。
ただし、コードをスキャンする前には見えにくく、迅速なテストビルドやデプロイに進む前には結果も評価しにくいです。
足りないところには基本的なスモークテストを追加します。
この作業は遅くするのではなく、むしろ速くしてくれます。適度なカバレッジを確保すれば、リファクタリング時にはるかに素早く動けるので、小さな投資に対する見返りが大きいです。
大きな C++ プロジェクトには、コード生成スクリプトだけでなく、通常はコード生成用データを集めるためにコードをパースするスクリプトもあります。
自動フォーマットがそれを壊す可能性がありますし、ユーザーが公開ヘッダファイルを脆弱なスクリプトでパースするという呪われたプロジェクトさえ見たことがあります。
あちこち触ることになる可能性が高く、ローカルに変更がたくさん積み上がると、関心事が混ざったコミットをうっかり作ってしまうことがあります。
そのとき CI は救世主になります。
その道を行くなら、少なくともブランチを squash してマージできる準備が整うまでは、すぐに削除せずコメントアウトして diff を単純に保つことを勧めます。
コード理解のためのツールと技法が抜けています。以前 Source Navigator という Tcl/Tk で書かれたツールを使っていましたが、コードベースの索引作成にとても優れていました。
現在のメソッドの呼び出し階層を確認でき、それを使って UML シーケンス図を作ることもできました。
似たツールとして以下の Source Insight もあります。
そしてノートが重要です。誰かに教えるつもりで書くのが肝心です。
長年の間にコード理解はかなり得意になり、かつては1日あたり約2億ドル規模を4〜5つの取引所で取引する Java のアルゴリズム取引コードベースを、1人で積極的にサポートし開発していました。
そのコードに関する文書は35MBもあり、キーパーソンリスクはさておき、責任感のある仕事でした。
正直なところ、ほとんどの大規模コードベースには過剰設計と重複が多くあります。
[1] References in "Source Insight" https://d4.alternativeto.net/6S4rr6_0rutCUWnpHNhVq7HMs8GTBs6...
大学のコンピュータアーキテクチャの授業でこの遺物を強制的に使わされました。他にはない奇妙な機能セットと ARM エミュレータ対応があったからです。
私たちはベアメタル ARM アセンブリプログラミングに使っていました。
std::cmakeを標準ライブラリに入れればゲームチェンジャーだ、というくだりで胃がひっくり返るような気分になった。会社やオープンソースプロジェクトが宣伝し販売している機能を提供するうえで絶対に必要ではないものを、チェーンソーで全部切り払えという助言は理解できるが、ものすごく危険でもある。
本質的には チェスタトンの柵 の問題で、何かがなぜそのソフトウェアに入ったのか、そして現在そのソフトウェアがどう使われているのかを完全に理解していなければ、取り除くことはできない。
最悪の場合、1か月ほど後にリリースした時点で、ユーザーが重要な機能が微妙に壊れていることに気づき、それが正確にどう壊れたのか追跡するのに何日も費やすことになる。
CI、リンター、ファジング、自動フォーマットなどを追加するのは良い考えだが、難しくもある。
ある人は VIM、別の人は emacs、また別の人は QTCreator、さらに別の人は VSCode を主に使っている、という状況だと、全員の足並みをそろえるのは非常に難しい。
新しいツールのインストールが必要な任意の手順なら実際には行われないし、リンターもプロジェクトを開いた瞬間に 2000 件以上の警告が出るなら役に立たない。
cpplintやclang-tidy、ファザーが IDE に統合されているのを見たことがないし、キー入力のたびに自動実行するには遅すぎる。自動フォーマットだけは時々統合されている。
こうしたものは各自の開発環境とは無関係にコマンドラインで実行できるので、テキストエディタが2種類使われているという理由で始める前から諦めず、少なくとも試すべきだ。
C++ でチームが何のツールもインストールしないつもりなら、苦労の道が開ける。ツールをコンテナ化して簡単にする案も検討に値する。
私たちはプロとして仕事をすべきで、その仕事に IDE からツールチェーンまで整える必要があるなら、学んで使うべきだ。
自分のコンピュータ、自分の IDE、自分の好きなやり方でビルドされ動作する、というのはソフトウェアというより工芸品に近い。
ローカルフックのインストールが任意だとしても、PR が CI で失敗するようにすれば、結局やることになる。
こういうのは本当に基本的なことだが、CI とインフラをきちんと扱う知識のギャップはかなり大きいように見える。
テスト、リント、ファジング、フォーマット、YAML 形式の検証、EOF 改行漏れの確認などをローカルで実行できるようにし、開発者が push 前に CI 失敗を防げるよう支援すべきだ。
リンターがプロジェクトを開くと何千もの警告を出すなら、リンターを追加する開発者はその変更をマージする前に警告を 0 件にすべきだ。
特定の警告やファイルを無効化する、一部を修正する、あるいはそれらを組み合わせることで達成できる。
最初のステップは、前のメンテナーに連絡して会いに行き、お茶かビールをおごって、最終的にコードベースについて話すことだと思う。
昔のメンテナー という魔法使いたちは、多くのことを教えてくれる。
そのほかの提案、たとえば複数のプラットフォームで動かせるようにしたり、テストを通したりすることは、堅牢性と理解につながる良いストレステストだと思う。
それでもまずは、先に通った人たちと話すという手近な果実を必ず取りに行くと思う。
まず少し自分で作業してみて、いくつかの場所で詰まってから前のメンテナーと話せば、ずっと生産的だ。
彼らもその努力をありがたく思うだろう。
テストがなく、ビルドが2回に1回失敗し、依存関係の情報が不明確で、ひどく古い OS が入った単一サーバーでしかビルドできないコードベースを引き継いだなら、前のメンテナーが本当に魔法使いなのか確信は持てない。
すべての問題が時間不足で生じたものなのか、それとも雇用の安定のために壊れたまま維持していた、あるいは新しいことを学びたがらなかった「魔法使い」なのかも見極める必要がある。
それが最善の場合で、よくあるのはどの質問にも「自分が引き継いだときにはすでにそうだった」という答えだけが返ってくることだ。
作業中にいくつものバグや問題に遭遇したので、雇用主向けにカスタムメイドされたそのソフトウェアを書いた開発者たちに連絡しようとした。
分かったのは、1人の契約者が書いたもので、その人は数年前に亡くなっていたということだった。
防衛産業ではこういうことはよくある。特定のシステムのために作られたカスタムの一品物が多く、特にハードウェア側では、装置を作ったエンジニアがずっと前に去っていたり退職していたりすることも珍しくない。
これは大いに役立つ。
C++ コードベースにテストがあるなんて、楽観的だと思う。
リファクタリングや改善に、なぜこれほど焦点が当たっているのかよく分からない。機能をコードに付け足して追加できるなら、他は触らずそうすればよい。
かなり大きな変更なら、レガシーコードから必要なものを外部関数呼び出し、ネットワーク層の導入、同じコードのライブラリ化といった形で切り出し、残りは新しい環境でやればよい。
今後そのコードを複数人で作業する必要があり、グループが容易に協業できるよう特定の前提や標準が必要でない限り、大規模なリファクタリングは試みないと思う。
付け足すやり方はある時点までは通用するが、変更した途端に何かが壊れ、散らかりすぎていて原因を必要以上に長く見つけられない状況が来る。
元記事の要旨は、大きな書き直しは避けつつ、凝集度を保ちながら変更しやすくするために整理整頓しよう、というものに近い。
ときには同じ機能を両方のバージョンに追加しなければならない。
最後のリリースが間近だと分かっているコードと、今後数十年さらに保守して機能を追加していくコードとでは、向き合い方が大きく異なる。
現実には、新機能が既存の動作の変更であることが多く、突然あちこちで重いリファクタリングをしなければならなくなる。
良い助言が多いスレッド。C/C++ に限らず付け加えると、バージョン管理システムを使える余裕があるなら、その価値を十分に活用すべき。
多くのチームは単なる共同作業ツールとしてしか使っていないが、それ以上のことができる。
履歴を取り込んで簡単なデータベースを作ればよい。RDB である必要はなく、JSON ファイルやスプレッドシートからでも始められる。
データ駆動のアプローチだけでも、ほぼ即座に有用な情報をたくさん引き出せる。
頻繁に変更されてきたファイルや関数は、今後の作業のホットスポットである可能性が高いので、単体テストを導入したりマージコンフリクトを減らしたりするなら、そこに集中すればよい。
一見離れて見えるファイルが頻繁に一緒に変わっているなら、コードを見ただけでは現れない暗黙の構造を示唆しているかもしれない。
各モジュールの実際の所有権モデルも履歴から推測でき、不明確な所有権はリファクタリングの必要性を知らせるシグナルになり得る。
C/C++ ではビルド時間の改善も、データに基づいて重要なモジュールに集中すべき。やみくもに任意のファイル依存を取り除くのではなく、頻繁に変わるモジュールを分割し、ヘッダー依存と組み合わせて実際のビルド時間への影響度をスコア化することもできる。
他の開発ツールと VCS を統合すればさらに多くのことができるし、LLM の時代にはプロジェクト履歴やメタデータをモデルに入れて、興味深い洞察を尋ねることもできそうだ。
巨大なコンテキストウィンドウなしでやるには専用のモデルエンジニアリングが必要かもしれないが、試す価値はあるという直感がある。
CI、リンター、ファジング、自動フォーマットなどを追加せよという助言は、もっと分けて考えるべき。
CI は、自分のコンピューターだけでなく他の場所でもビルドできることを保証し、コンパイル起因のリグレッションを防ぐべき。
コンパイラ警告や静的解析器はおおむね自分より賢いので、ポインタで怪しいことをしていて怖いという警告が出たら、確認すべき強いシグナルだ。
単体テストは、重要なコードが低いレベルまで正確に期待どおりのことをしているか検証すべきで、実際にはそうでない可能性が高いので、その理由を理解する必要がある。
何かを直すと別のものが壊れることがある。既存コードがバグのある動作を前提に書かれている可能性があるためだ。
自動フォーマットは優先事項ではなく、既存メンテナーのスタイルに従うほうがよいと思う。
引き継いだ C++ コードベースの最終段階がメモリ安全な言語への書き直し、というのも合わない可能性が高い。
壊れていないものに追加の作業リソースを得るのは難しく、C++ だけでなく追加の言語知識も必要になり、テストがより複雑になり得る。
メモリや性能の制約により、複数言語で書くのに適していない可能性も高いし、そもそもレガシーコードベースを引き継いだということは、時間・お金・知識といったリソースが不足していて新規に書けなかったという認めに近い。
「X で書き直す」は、流行という理由で複雑さを増やすだけ。
すでに C++ でコードベースの大部分を書き直しているなら、より制限的な C++ のサブセットに従うほうがよく、High Integrity C++ は悪くないと思う。
最新の MISRA 標準を入手できるなら、それも良い可能性が高い。
新しい言語の鋭い角をチーム全体に改めて学ばせるより、すでに知っている言語を使いつつ、既知の落とし穴を避けるガイドラインを強制するほうがよい。
著者が BOM 自動化、パッケージのバージョン管理、依存関係の出所などをかなり批判したあと、git submodules をパッケージマネージャーより良いと提案しているのは妙だ。
こうした批判をする前に vcpkg を使ってみるべき。
多少の尖った部分はあるが、ほぼすべて vcpkg で直感的に満たせる。
依存関係の更新は git submodules より少し難しいが、これはバグというより機能に近いと思う。依存関係は個別のサンドボックスでビルドされたあと、指定ディレクトリにインストールされる。
vcpkg は公式リポジトリの代わりに内部リポジトリをレジストリとして設定し、vendored-in 的な性格を保つことができる。また、ツールチェーンをチェーンロードして固定されたフラグセットですべてをコンパイルさせ、ポートごとのカスタマイズも許可する。
こうした抽象化が有用だからこそパッケージマネージャーは人気があるのであり、誰もがコンパイルフラグ、マクロ、警告などを含む終わりのない文字列を自分で扱わなくて済む。
記事は面白く、学びもあった。ただ、人々が「メモリ安全な言語で書き直す」と言うとき、どの言語を指しているのか気になった
Go、Java、C#で一部を書き直そうという話なのか、それとも否定の余地を残しつつRustで書き直せという皮肉なのかが気になる
結論はチームと制約に完全に依存する。たとえばガベージコレクションが可能か、可能ならGoがよい選択なのか、セキュリティが最優先なのか、といった点が重要
ほとんどのC++開発者は、概してRustを容易に使え、同等の性能を得られると見ている
ただし、そもそもプロジェクトがC++であるべき十分な理由がなかった場合もあり、Javaでの書き直しが成功した事例も見たことがある
Appleは一部のC++コードをSwiftで書き直している
チームや会社が扱いやすい言語、というのがよい経験則
ここまで来ると、書き手が邪悪な暗号化されたRustプログラマーなのではないかと疑う段階に入ったわけだ。そもそもRustは出てこなかったので、直接文句を言えなかったからだろうが