LoginSignup
35
24

More than 5 years have passed since last update.

[C++] filesystemの標準入りが嬉しすぎてライブラリを作った話

Last updated at Posted at 2018-12-18

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メンバ関数の出力が期待通りにならないなど2filesystemは導入されたばかりでいくつかのバグを抱えています。

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のサイズがかなりデカくなります。ここをどうにか出来ないか検討中。。。

travis.yml
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に現れています。

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関係のエラーを吐いている模様。

cov-int/build-log.txt
"/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++では大きな問題を引き起こす場合があります。

それは、バイナリにシンボルが埋め込まれていないとカバレッジの計算に含まれないというものです。

どういうことか分かりやすい例を挙げて説明します。以下のコードをテストしたいとします。

sample.hpp
#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_;
};

これをテストするために、あなたは次のようなテストを書くでしょう。

test.cpp
#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();
}

これをコンパイルして、カバレッジを吐き出すと以下のようになります。
LCOV_-_coverage.info_-_lcov_sample.hpp_-_2018-11-29_20.38.53.png

お分かりでしょうか。ムーブコンストラクタなど一部のメンバ関数が認識されていません。そのため、テストカバレッジは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で快適生活かな。。。


  1. Boost.coroutine2を使うという手もありますが、こちらは処理がインライン化されずパフォーマンス面でもあまり好ましくないので、一度std::vectorを作成することにしています。 

  2. 詳細はこのPull Requestを参照 

  3. doctestが強そうなので検討中。 

  4. GCCの-fkeep-inline-functionsというオプションを利用するという手もありますが、コンパイル時間がかなり伸びる上、メモリ消費量がいっきに膨れ上がり、出力されるバイナリが数十〜数百MBとかになってしまうので諦めました。 

35
24
5

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
35
24