C++ Advent Calendar 2018
この記事はC++ Advent Calendar 2018 19日目の記事です。
18日目 -> 「C++03/C++11(14)/C++17でのmember detector」
20日目 -> 「みんなも使おうif constexpr」
はじめに
ついに!!!!!!
C++17から!!!!!
filesystem
が標準入りしましたー!(゚∇゚ノノ"☆(゚∇゚ノノ"☆(゚∇゚ノノ"☆パチパチパチ!!!
さらに今年2018年、libstdc++, libc++にfilesystem
ヘッダが追加され、gccやclangでもBoostを使わずにfilesystem
が利用できるようになりました。
今までBoost.Filesystemに依存していたプラグラムも、これでBoost要らずとなります。
C++でCLIツールなどを作るのもかなり楽になるでしょう。
そんなfilesystem
を記念して、Pythonのglob
モジュールを真似したcppglobというライブラリを作成しました。
今回はそのライブラリの紹介と、作成時に苦労した点などについて説明します。
cppglobの紹介
cppglobの概要
Pythonのglobモジュールのように、ファイル名(またはディレクトリ名)をワイルドカードで検索します。
例えば、docs
ディレクトリ直下にあるPDFファイル一覧を検索したい時、"docs/*.pdf"
という文字列を与えることで、ファイル名の一覧を返します。
また、ディレクトリの再帰探索もサポートしているので、例えばinclude
というディレクトリより下の階層にある、拡張子がhpp
のファイルの一覧は、include/**/*.hpp
で取得できます。
ちなみに、**
という文字列を与えた場合は全てのディレクトリ・及びファイルを再帰的に探索するので、結果はstd::filesystem::recursive_directory_iterator
と同じになります。
POSIX APIでglob関数というのがあるのですが、そちらはWindowsのパスを扱えない上、glob_freeを呼ばなければならないなど使い勝手が悪く、さらに再帰的探索が出来ないので、今回自作することにしました。
本当は名前空間もglob
にしようと思っていたのですが、この関数名との衝突を避けるためにcppglob
にしました。
cppglobの使い方
使い方はPythonのglob
モジュールとほとんど同じです。glob関数でファイル名(ディレクトリ名)の配列を取得、iglob関数でイテレータを取得します。
#include <vector>
#include <list>
#include <algorithm>
#include <filesystem>
#include <cppglob/glob.hpp> // cppglob::glob
#include <cppglob/iglob.hpp> // cppglob::iglob
namespace fs = std::filesystem;
int main() {
// カレントディレクトリのファイル及びディレクトリをすべて取得する(隠しファイルは除く)
std::vector<fs::path> entries = cppglob::glob("./*");
// dir_a/ディレクトリ直下にあるディレクトリを全て取得する
std::vector<fs::path> dirs = cppglob::glob("dir_a/*/");
// dir_b/ディレクトリより下の階層にあるテキストファイルをすべて取得する
std::vector<fs::path> files = cppglob::glob("dir_b/**/*.txt", true);
// docs/ディレクトリ直下にあるPDFファイルを名前順で取得
std::vector<fs::path> pdf_files = cppglob::glob("docs/*.pdf");
std::sort(pdf_files.begin(), pdf_files.end());
// iglobを使用したバージョン
cppglob::glob_iterator it = cppglob::iglob("docs/*.pdf"), end;
std::list<fs::path> pdf_file_list(it, end);
pdf_file_list.sort();
}
なお、現状ではglob関数のシグネチャは以下のようになっています。
std::vector<fs::path> glob(const fs::path&, bool recursive = true);
なお、filesystemの仕様上Visual Studioで文字列を直接渡す場合は、必ずネイティブフォーマット(区切り文字'\')で渡す必要があります。
std::vector<fs::path> entries = cppglob::glob(L".\\*");
Pythonのiglobの実装を見ると、yieldを用いて実装しているので、そのまま移植するのが難しく、今のところは一度vectorを作成してからその先頭イテレータを返す方式で実装しています。1
というかiglobなんて誰が使うんだこれ。
cppglob::glob_iterator iglob(const fs::path&, bool recursive = true);
cppglob::glob_iterator iglob(); // 終端を指すイテレータを返す
あと、本家と同じようにescape
関数も一応実装しています。この関数は、パス名に含まれるワイルドカードをエスケープします。
使い方はこんな感じ。
assert(cppglob::escape("?.txt") == fs::path("[?].txt"));
assert(cppglob::escape("*.txt") == fs::path("[*].txt"));
assert(cppglob::escape("[abc].txt") == fs::path("[[]abc].txt"));
正直この関数も使い道が良く分からない。
今後の方針
遅いです。
現状ではPythonのglobのソースコードをC++で置き換えただけなので、実行速度が遅いです。
リリースモードでビルドすればPythonよりは速くなると思いますが、実行速度を求めるC++erの方々から苦情が来そうなので、もっと高速化を狙っていきたいと思っています。
というかもとの(Pythonの)コードが速度面を一切考えずに設計されているので、高速化するには大々的に書き換える必要がありそう。。。
一度vectorを作成して返すのも処理として無駄が多いような気がするので、以下のようにコールバック関数を渡せるcglob
関数の実装も検討しています。
void cglob(const fs::path&, function_ref<void(fs::path&)>, bool recursive = true);
苦労した点
filesystemのバグ?
Kurt Guntheroth氏の「Optimized C++」という本では、標準ライブラリを使う際の問題点として以下のような項目を挙げています。
- 標準ライブラリ実装にもバグはある
- 標準ライブラリ実装はC++標準に適合しているとは限らない
- 標準ライブラリ開発者にとって性能は一番重要なことではない
- ライブラリ実装は最適化の試みの邪魔をする
- C++標準ライブラリのすべての部分が等しく成り立つわけではない (
std::vector<bool>
など) - 標準ライブラリは最良のネイティブ関数ほどには効率的でない
特にfilesystem
のような新しく導入されたばかりのライブラリは多くのバグを含んでいる可能性があります。
cppglobを書き始めた頃は、以下のようなテストを書いてました。
vec = cppglob::glob("././///./*");
std::vector<fs::path> corrects = {"./././a", "./././f.txt", "./././e"};
UNORDERED_COMPARE_RESULTS(vec, corrects);
しかしこの部分のテストがMSVCとlibc++上でいつもFailするので、おかしいなーとか思って4時間くらいデバッグして気づいたんですが、パス名の区切り文字('/', '\\')が連続している場合の挙動が実装系によって微妙に異なるようだというのが発覚しました。
詳しいことは調査中なのですが、libstdc++で一度parent_path
で分解した後にoperator/
で再度連結すると、勝手に.
が追加されてglob関数の返り値がおかしくなるようです。
結局、このテストはコメントアウトして外しました。
他にも、GCCのバージョン8.1だとlexically_norm
メンバ関数の出力が期待通りにならないなど2、filesystem
は導入されたばかりでいくつかのバグを抱えています。
Visual Studioにおけるstring_viewの謎挙動
appveyorでテストを動かした時に、何故かプログラムが終わらないという問題が発生したのですが、Linux上で開発しているため原因が分からず、Memory Sanitizerを利用しても問題は発生せず。
さすがにおかしいと思ったので、あらゆる場所に以下のようなコードを挿入しデバッグするという古典的な手法に出ることに。
std::cout << __LINE__ << std::endl;
変数の中身なども表示したりしてひたすらpushすること3時間。ついに問題箇所が判明しました。
it1 = cp++; // it1, cpの型はstd::wstring_view::const_iterator
cp
の値は文字列の終端、つまりもとのwstring_view
オブジェクトのend()
メンバ関数の返り値と同じです。
原因は、終端のイテレータをインクリメントしていたことでした。まさかこんなことでプログラムが進まなくなるとは思ってもいなかったのでビックリ。
インクリメント処理をうまく調整して終端イテレータをインクリメントしないように調整したところ、正常に動作するようになりました。
googletestがSegFaultを吐く
Google Testのドキュメントによると、EXPECT_EQ
マクロは等号比較演算子が定義されていればユーザー定義型に対しても動作するとあります。
なので、文面通りだと下記のテストは問題なくFailするはずです。
TEST(test, test) {
fs::path a("test.txt"), b("test.png");
EXPECT_EQ(a, b);
}
しかし、このコードは問答無用でSegmentaion Faultを吐きます。
[==========] Running 1 test from 1 test case.
[----------] Global test environment set-up.
[----------] 1 test from test
[ RUN ] test.test
Segmentation fault (core dumped)
普通の型ならEXPECT_EQ
は問題なく動作するのですが、filesystem::path
の等号比較の結果がfalseだと何故かSegmentation faultを起こすという謎挙動。
念のためGoogle Testを最新のものに変えても改善されなかったので、Google Testのリポジトリに行ってissueの検索をかけてみると、案の定似たようなissueを発見。
https://github.com/abseil/googletest/issues/1614
このissueはまだOpenなので、依然として解決されていない模様。暇な人いたらPull Requestでも送ってあげてください。
追記: 自分でPull Request送りました。
https://github.com/abseil/googletest/pull/2002
追記: 現在ではPull Requestがmergeされ、この問題は起こらなくなっています。
https://github.com/abseil/googletest/pull/2002
googletestはmaintainerの方が忙しいのか、ほとんどPull Request任せ状態。
というかよく見たら、最終リリースが2016年とかになっていますね。string_view
における問題なども生じているようで、C++17への対応はほとんどなされていないようです。
なんだか雲行き怪しいですね。そろそろ他のテストフレームワークに乗り換える時が来ているのでしょうか?3
Mac OSX on Travis CIでfilesystemを使う。
Travis CIにデフォルトで入ってるコンパイラは最新ではないので、filesystem
を使えません。
なんとかMacにGCC 8をインストールできないかなーといろいろ模索していたのですが、Macに一ミリも触れたことがないので解説記事読んでもよく分からず、エラーが多発するだけでした。
GCCは諦めて、、以下のようにHomebrewでLLVM 7.0をインストールし、Travis CI上にcacheすることでどうにかしています。ただし、LLVMだと余計なバイナリもかなり付属してくるのでcacheのサイズがかなりデカくなります。ここをどうにか出来ないか検討中。。。
install:
- |
if [ "${TRAVIS_OS_NAME}" = "osx" ]; then
if [ -e /usr/local/Cellar/llvm/7.0.0_1/bin ]; then
echo "Clang cache found."
ln -s /usr/local/Cellar/llvm/7.0.0_1 /usr/local/opt/llvm
else
echo "Installing clang..."
rm -rf /usr/local/Cellar/llvm/7.0.0_1
brew install --with-clang llvm
ls -l /usr/local/Cellar/llvm/7.0.0_1
fi
CC_PREFIX=/usr/local/opt/llvm
export PATH="${CC_PREFIX}/bin:$PATH"
export LDFLAGS="-L${CC_PREFIX}/lib -Wl,-rpath,${CC_PREFIX}/lib"
export CPPFLAGS="-I${CC_PREFIX}/include"
fi
結局、これも解決するのに2日間ぐらい費やしました。
GCC8.1とLCOVの相性が悪い
Travis CIでapt使ってgccをインストールするとgcc 8.1が入ります。しかし、これがlcovと相性悪い。
LCOVの最新のバージョンは1.13ですが、これを使用してもOverlong recordなどという謎のエラーを生じてうまく動きませんでした。
手元のgcc 8.2だと問題なく動作するので、これはおそらくgcov 8.1のバグなのではないかと思い、DockerでTravis CIと同じような環境を用意して、試しにgcc 8.1でコンパイルした後にlcovを使ってみました。
すると案の定似たようなエラーが。
$ lcov --gcov-tool gcov-8 -c -d src/CMakeFiles/cppglob.dir/ -b ../src -o coverage.info
Capturing coverage data from src/CMakeFiles/cppglob.dir/
Found gcov version: 8.1.0
Scanning src/CMakeFiles/cppglob.dir/ for .gcda files ...
Found 2 data files in src/CMakeFiles/cppglob.dir/
Processing cppglob.dir/fnmatch.gcda
geninfo: WARNING: /home/kogia_sima/cppglob/build/src/CMakeFiles/cppglob.dir/fnmatch.gcno: Overlong record at end of file!
geninfo: WARNING: cannot find an entry for #home#kogia_sima#cppglob#src#fnmatch.cpp.gcov in .gcno file, skipping file!
geninfo: WARNING: cannot find an entry for #usr#include#c++#8#bits#alloc_traits.h.gcov in .gcno file, skipping file!
geninfo: WARNING: cannot find an entry for #usr#include#c++#8#bits#allocated_ptr.h.gcov in .gcno file, skipping file!
geninfo: WARNING: cannot find an entry for #usr#include#c++#8#bits#allocator.h.gcov in .gcno file, skipping file!
...
geninfo: WARNING: cannot find an entry for #usr#include#x86_64-linux-gnu#c++#8#bits#gthr-default.h.gcov in .gcno file, skipping file!
Finished .info-file creation
そしてこのエラーに関する情報が調べても全然出てこない!
丸一日調べ上げてようやく手がかりになる情報を見つけました。
https://github.com/linux-test-project/lcov/issues/38#issuecomment-371138810
上記のissue commentによると、gcc 8からgcovの挙動が変わり、それに対応した変更をコミットしたようです。
このコミット、まだ最新のリリースに含まれていません。
なので、ソースをcloneして該当するコミットをcheckoutしたところ、この問題は解決しました。
この部分の苦労も、travis.yml
に現れています。
# code coverage
after_success:
- if [ "${BUILD_TYPE}" == "Coverage" ]; then
git clone https://github.com/linux-test-project/lcov;
pushd lcov;
git checkout e675080;
popd;
./lcov/bin/lcov --gcov-tool `which gcov-8` -c -d src/CMakeFiles/cppglob.dir -b ../src -o coverage.info;
./lcov/bin/lcov --remove coverage.info '/usr/*' -o coverage.info;
./lcov/bin/lcov --list coverage.info;
bash <(curl -s https://codecov.io/bash) || echo "Codecov did not collect coverage reports";
fi
Coverity Scanも駄目だった
いつもの流れでCoverity Scanをやろうとしたのですが、以下のようなエラーが出て上手くいかず。
[WARNING] Emitted 0 C/C++ compilation units (0%) successfully
[WARNING] Recoverable errors were encountered during 1 of these C/C++ compilation units.
Coverity Scanのログを見ると、どうもtype_traits関係のエラーを吐いている模様。
"/usr/include/c++/8/type_traits", line 1049: error #255: type name is not
allowed
: public __bool_constant<__is_assignable(_Tp, _Up)>
^
"/usr/include/c++/8/type_traits", line 1049: error #255: type name is not
allowed
: public __bool_constant<__is_assignable(_Tp, _Up)>
^
"/usr/include/c++/8/type_traits", line 1049: error #20: identifier
"__is_assignable" is undefined
: public __bool_constant<__is_assignable(_Tp, _Up)>
^
"/usr/include/c++/8/utility", line 307: error #1937: pack expansion does not
make use of any argument packs
using __type = _Index_tuple<__integer_pack(_Num)...>;
^
"/usr/include/c++/8/utility", line 329: error #1937: pack expansion does not
make use of any argument packs
= integer_sequence<_Tp, __integer_pack(_Num)...>;
^
"./include/cppglob/config.hpp", line 11: error #35: #error directive: This file
requires compiler and library support for the ISO C++ 2017 standard.
This support must be enabled with the -std=c++17 or -std=gnu++17
compiler options.
# error This file requires compiler and library support \
おそらくCoverity ScanがまだC++17に対応していないことが原因だと思います。
https://community.synopsys.com/s/article/Coverity-build-errors-when-using-the-C-17
結局、Coverity Scanを利用するのも諦めることになりました。
テストカバレッジを正しく測定できない
C/C++ではgcovやlcovを使ってテストカバレッジを収集するのが習わしですが、C++では大きな問題を引き起こす場合があります。
それは、バイナリにシンボルが埋め込まれていないとカバレッジの計算に含まれないというものです。
どういうことか分かりやすい例を挙げて説明します。以下のコードをテストしたいとします。
#include <iostream>
#include <string>
#include <utility>
class SampleClass {
public:
SampleClass() = delete;
SampleClass(const std::string& name) : name_(name) {}
SampleClass(std::string&& name) noexcept : name_(std::move(name)) {}
SampleClass(const SampleClass& other) noexcept : name_(other.name_) {}
SampleClass(SampleClass&& other) noexcept : name_(std::move(other.name_)) {}
virtual ~SampleClass() {}
const std::string& name() const noexcept { return name_; }
virtual void hello() const { std::cout << "My name is " << name_ << std::endl; }
private:
const std::string name_;
};
これをテストするために、あなたは次のようなテストを書くでしょう。
#include <gtest/gtest.h>
#include "./sample.hpp"
TEST(add_test, generic) {
SampleClass personA{"Yamada"};
SampleClass personB{personA};
EXPECT_EQ(personA.name(), "Yamada");
EXPECT_EQ(personB.name(), "Yamada");
}
int main(int argc, char* argv[]) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
これをコンパイルして、カバレッジを吐き出すと以下のようになります。
お分かりでしょうか。ムーブコンストラクタなど一部のメンバ関数が認識されていません。そのため、テストカバレッジは80%となってしまっていました。
何故このような現象が起きたのでしょうか。
おそらくですが、バイナリに埋め込まれなかった関数(インライン化されたか、inline指定されていてかつ一度も呼び出されない関数)は、gcc/g++がデバッグ情報を生成しないため、その部分だけコードを追跡することが出来ず、結果として正しいカバレッジが出力されなかったと考えられます。
上記のコードでは、inlineでかつ一度も呼ばれない、ムーブコンストラクタなどがバイナリに埋め込まれず、カバレッジ計測に含まれなかったことになります。
実際のカバレッジは57%なのに、80%と誇張される。つまりカバレッジ詐欺ですね。C++のカバレッジは当てになりません。
もう一度言います。
C++のカバレッジは当てになりません。
特にシンボルが埋め込まれにくいヘッダーオンリーのライブラリでは要注意です。以下はtiny-dnnの例です。
https://coveralls.io/builds/19677898/source?filename=tiny_dnn/core/framework/op_kernel.h
READMEに貼ってあるバッジにはカバレッジ81%と書かれていますが、これはあまり当てにしないほうが良いでしょう。
自分の場合は、クラスの宣言と実装をわけてプロプロセッサで上手く分岐するようにしてカバレッジを計測しています。
というかクラスのカバレッジを上手く計測するにはこうするしか無いでしょう。4
https://codecov.io/gh/machida-mn/cppglob/src/master/include/cppglob/glob_iterator.hpp
が、ここまでするくらいだったら最初からカバレッジの計測を諦めたほうが良いかもしれません。
追記 (2018/12/19)
@yumetodoさんからの指摘によると、カバレッジ計測において%はあまり気にせずに網羅していない部分が分かれば良いみたいです。
最新のC++を使えとは言うけれど。。。
様々な問題に直面したせいで、こんな小さなライブラリを作るのにかなりの日数を費やしてました。
問題と言っても、ほとんどはC++のコードではなく、C++17がまだ不安定であることによるバグや外部サービスの未対応によるもの。
C++は最新規格を使えとか言うけれど、filesystem
に関しては正直今の不安定な状態でこれ以上使う気にならないので、やはり安定するまではC++14で快適生活かな。。。
-
Boost.coroutine2を使うという手もありますが、こちらは処理がインライン化されずパフォーマンス面でもあまり好ましくないので、一度
std::vector
を作成することにしています。 ↩ -
詳細はこのPull Requestを参照 ↩
-
GCCの
-fkeep-inline-functions
というオプションを利用するという手もありますが、コンパイル時間がかなり伸びる上、メモリ消費量がいっきに膨れ上がり、出力されるバイナリが数十〜数百MBとかになってしまうので諦めました。 ↩