Simply Patrick

C++ 套件管理: 使用 Conan

手動管理 C/C++ 套件

想像你需要使用 LLVM 開發一個程式,如果在 macOS 上,最簡單的安裝方法是用 Homebrew,一行就搞定: brew install llvm。但 Homebrew 上的版本不一定是最新的而且也無法同時安裝不同的版本 (例如 LLVM 5.0/6.0 共存) 或同版本但不同設置(例如 LLVM 6.0 的 debug/release 版本);而在 Linux 及 Windows 上也有各自不同的安裝問題。

安裝完畢後,麻煩才剛開始:通常第一步是設置編譯環境的 CPPFLAGS 及 LDFLAGS:

For compilers to find this software you may need to set:
    LDFLAGS:  -L/usr/local/opt/llvm/lib
    CPPFLAGS: -I/usr/local/opt/llvm/include

根據不同的開發環境或編譯器,設置的方法也都不同。最後設置完後編譯及連結也不一定能成功,因為 LLVM 本身可能又依賴其他套件,還需要把它的 dependencies 一一安裝及設置。

Conan 介紹

Conan 企圖幫助 C/C++ 脫離這個窘境:沒有一個像樣的套件管理 (package management) 工具。

我建議先讀一下 Conan 的文檔來了解如何安裝 Conan 以及利用 Conan 來使用現成的程式庫

如果你是使用 CMake,事情會簡單一些,因為 Conan 提供的 CMake 整合還算好用,使用範例可以參考這個基於 LLVM 的小程式: clike

下載套件

除了預設的 conan-center,Conan 官網建議也可以到 bincraftersconan-community 裡尋找,可以用下列指令新增這兩個 remote:

$ conan remote add conan-community https://api.bintray.com/conan/conan-community/conan
$ conan remote add bincrafters https://api.bintray.com/conan/bincrafters/public-conan

之後就可以指定 remote 搜尋 package:

$ conan search ffmpeg -r=bincrafters
Existing package recipes:

ffmpeg/[email protected]/stable

目前 server 上面的套件數量不算多,有可能你會找不到你想要的,因此你不能期待很快地有人會幫你做出來,相反地你必須學會自己創建套件才能充分得到使用 Conan 的好處。

創建 package

自己創建一個套件其實也不難,基本的概念如下:

整個建置過程需要的目錄結構及流程會是由 Conan 來控制,但你需要提供一個 conanfile.py 描述每個步驟裡的需要執行的動作。Conan 會把打包的套件放在一個 local cache 裡讓其他的程式來使用。

以下示範如何下載 LLVM 6.0 的原始碼, 編譯,最後把它包裝成套件:

from conans import ConanFile, CMake
import glob
import os

class LlvmConan(ConanFile):
    name = "LLVM"
    version = "release_60"
    license = "LLVM Release License"
    url = "https://github.com/p47r1ck7541/llvm-60"
    description = "%s %s" % (name, version)
    settings = "os", "compiler", "build_type", "arch"
    generators = "cmake"

    def source(self):
        self.run("git clone https://github.com/llvm-mirror/llvm -b %s --depth 1 src" % self.version)

    def build(self):
        cmake = CMake(self)
        cmake.configure(source_folder="src")
        cmake.install()

    def package(self):
        # nothing to do here now because we reuse 'cmake install' to package files
        pass

    def package_info(self):
        self.cpp\_info.libs = \[os.path.basename(a) for a in glob.glob(os.path.join(self.package\_folder, "lib", "*.a"))\]
        self.cpp_info.cppflags = \["-std=c++11", "-fno-rtti"\]
        self.cpp_info.exelinkflags = \["-lcurses", "-lz"\]

幾個關鍵步驟:

  1. source(): 利用 git clone 下載原始碼
  2. build(): 負責編譯,這裏我們直接使用 Conan 提供的 CMake class 來控制 cmake 的執行,值得注意的是我們直接利用 cmake.install() 來取代實際打包套件的工作。
  3. package(): 如上解釋,不需做任何事。
  4. package_info(): 提供套件的相關使用資訊。例如 cppflagsexelinkflags 是寫死的,但 libs 則是運行 Python script 搜尋編譯結果找出來的。

完整範例可以參考這個 GitHub project: conan-llvm-60

實際運作過程如下(中間請自行快轉):

總結

就簡化自己手動管理 C/C++ 套件的工作來看,我認為 Conan 提供的好處值得你花時間去學它,至於它是否能成為主流的 C/C++ 套件管理工具,那就等待時間來驗證了。


C++ 單元測試: 使用 Catch2

Catch2 介紹

介紹一下最近有在使用的一個 C++ test framework, Catch2,它對自己的描述是:

A modern, C++-native, header-only, test framework for unit-tests, TDD and BDD - using C++11, C++14, C++17 and later (or C++03 on the Catch1.x branch)

我主要的用途是做一些 C++ 小程式的簡單驗證,因此簡單易用是我首要的選擇條件,而 Catch2 應該是目前找的到最好的選擇,例如這是個 Huffman coding 的測試代碼:

TEST_CASE("HuffmanEncoding") {
    vector<Symbol> symbols{
            {'a', 8.17}, {'b', 1.49}, {'c', 2.78}, {'d', 4.25},
            {'e', 12.7}, {'f', 2.23}, {'g', 2.02}, {'h', 6.09},
            {'i', 6.97}, {'j', 0.15}, {'k', 0.77}, {'l', 4.03},
            {'m', 2.41}, {'n', 6.75}, {'o', 7.51}, {'p', 1.93},
            {'q', 0.10}, {'r', 5.99}, {'s', 6.33}, {'t', 9.06},
            {'u', 2.76}, {'v', 0.98}, {'w', 2.36}, {'x', 0.15},
            {'y', 1.97}, {'z', 0.07},
    };

    HuffmanEncoding(symbols);

    REQUIRE(symbols['a' - 'a'].code == "1110");
    REQUIRE(symbols['e' - 'a'].code == "100");
    REQUIRE(symbols['t' - 'a'].code == "000");
    REQUIRE(symbols['z' - 'a'].code == "001001000");
}

可以看到代碼非常地簡潔而易懂,你只要學怎麼用 TEST_CASEREQUIRE 就好了。

如何使用 Catch

中文的介紹可以參考 “C++ 的单元测试工具 —— Catch | 时习之”,只是關於如何 #include "catch.hpp" 的部分我覺得描述的不是很精確,建議可以讀一下 “Why do my tests take so long to compile?”

真的很介意 compile time 受到影響的人,可以考慮關掉一些沒用到的功能來加速編譯: Catch2/configuration

Catch2 的原理

神奇的 REQUIRE

有用過其他的 C++ test framework 例如 googletest 的人,應該會對 Catch 的 REQUIRE 感到好奇:

為什麼 googletest 需要使用較不自然的 ASSERT_EQ(a, b), ASSERT_NE(a, b) 而 Catch 只需要寫 REQUIRE(a == b)REQUIRE(a != b)

拆解表達式 (Decomposing an expression)

考慮下列這個有問題的 Factorial 實作及它的測試代碼:

int Factorial( int number ) {
   return number <= 1 ? number : Factorial( number - 1 ) * number;  // fail
}

TEST_CASE( "Factorial of 0 is 1 (fail)", "[single-file]" ) {
    REQUIRE( Factorial(0) == 1 );
}

REQUIRE(Factorial(0) == 1); 會輸出下列的結果:

010-TestCase.cpp:14: failed: Factorial(0) == 1 for: 0 == 1

這表示 REQUIRE 需要從 Factorial(1) == 1 拆解出下列五個資訊:

  1. 整個表達式 “Factorial(0) == 1” 的字串值
  2. 表達式左邊 Factorial(0) 的結果,也就是 0
  3. 使用的 operator, 也就是 “==”
  4. 表達式右邊 1 的值,也就是常量 1
  5. 表達式的結果,在這個例子是 false

也就是說 Catch2 必須在 compile time 拆解這個表達式,這裡需要用到一些 template metaprogramming 的技巧。

REQUIRE

REQUIRE 是用 macro 來定義的,所以整個表達式的字串值可以簡單用 stringification 拿到。

#define REQUIRE( ... ) INTERNAL_CATCH_TEST( "REQUIRE", Catch::ResultDisposition::Normal, __VA_ARGS__  )
#define INTERNAL_CATCH_TEST( macroName, resultDisposition, ... ) \
    do { \
        Catch::AssertionHandler catchAssertionHandler( macroName, CATCH_INTERNAL_LINEINFO, CATCH_INTERNAL_STRINGIFY(__VA_ARGS__), resultDisposition ); \
        INTERNAL_CATCH_TRY { \
            CATCH_INTERNAL_SUPPRESS_PARENTHESES_WARNINGS \
            catchAssertionHandler.handleExpr( Catch::Decomposer() <= __VA_ARGS__ ); \
            CATCH_INTERNAL_UNSUPPRESS_PARENTHESES_WARNINGS \
        } INTERNAL_CATCH_CATCH( catchAssertionHandler ) \
        INTERNAL_CATCH_REACT( catchAssertionHandler ) \
    } while( (void)0, false && static_cast<bool>( !!(__VA_ARGS__) ) ) // the expression here is never evaluated at runtime but it forces the compiler to give it a look
    // The double negation silences MSVC's C4800 warning, the static_cast forces short-circuit evaluation if the type has overloaded &&.

Catch::Decomposer

而 2, 3, 4 的部分就要靠 Catch::Decomposer,這裡用的 template metaprogramming 技巧叫作 expression template

struct Decomposer {
    template<typename T>
    auto operator <= ( T const& lhs ) -> ExprLhs<T const&> {
        return ExprLhs<T const&>{ lhs };
    }

    auto operator <= ( bool value ) -> ExprLhs<bool> {
        return ExprLhs<bool>{ value };
    }
};

Decomposer<= 跟表達式的左邊結合變成 ExprLhs<T const &>ExprLhs 是個 C++ template:

ExprLhs

template<typename LhsT>
class ExprLhs {
    LhsT m_lhs;
public:
    explicit ExprLhs( LhsT lhs ) : m_lhs( lhs ) {}

    template<typename RhsT>
    auto operator == ( RhsT const& rhs ) -> BinaryExpr<LhsT, RhsT const&> const {
        return { compareEqual( m_lhs, rhs ), m_lhs, "==", rhs };
    }
    auto operator == ( bool rhs ) -> BinaryExpr<LhsT, bool> const {
        return { m_lhs == rhs, m_lhs, "==", rhs };
    }

    template<typename RhsT>
    auto operator != ( RhsT const& rhs ) -> BinaryExpr<LhsT, RhsT const&> const {
        return { compareNotEqual( m_lhs, rhs ), m_lhs, "!=", rhs };
    }
    auto operator != ( bool rhs ) -> BinaryExpr<LhsT, bool> const {
        return { m_lhs != rhs, m_lhs, "!=", rhs };
    }

    template<typename RhsT>
    auto operator > ( RhsT const& rhs ) -> BinaryExpr<LhsT, RhsT const&> const {
        return { m_lhs > rhs, m_lhs, ">", rhs };
    }
    template<typename RhsT>
    auto operator < ( RhsT const& rhs ) -> BinaryExpr<LhsT, RhsT const&> const {
        return { m_lhs < rhs, m_lhs, "<", rhs };
    }
    template<typename RhsT>
    auto operator >= ( RhsT const& rhs ) -> BinaryExpr<LhsT, RhsT const&> const {
        return { m_lhs >= rhs, m_lhs, ">=", rhs };
    }
    template<typename RhsT>
    auto operator <= ( RhsT const& rhs ) -> BinaryExpr<LhsT, RhsT const&> const {
        return { m_lhs <= rhs, m_lhs, "<=", rhs };
    }

    auto makeUnaryExpr() const -> UnaryExpr<LhsT> {
        return UnaryExpr<LhsT>{ m_lhs };
    }
};

ExprLhr 利用 operator overloading 來知道是用那個 operator 跟表達式的右邊做運算,最後把整個表達式轉換成一個 BinaryExpr

BinaryExpr

template<typename LhsT, typename RhsT>
class BinaryExpr  : public ITransientExpression {
    LhsT m_lhs;
    StringRef m_op;
    RhsT m_rhs;

public:
    BinaryExpr( bool comparisonResult, LhsT lhs, StringRef op, RhsT rhs )
    :   ITransientExpression{ true, comparisonResult },
        m_lhs( lhs ),
        m_op( op ),
        m_rhs( rhs )
    {}
};

之前我們提到的 2, 3, 4, 5 的資訊就存在 BinaryExprm_lhs, m_op, m_rhs, 及 ITransientExpression 裡面。

搭配 CLion 使用

另外我還蠻推薦使用 JetBrains 的 C++ IDE: CLion,它目前有內建 Catch2 的支援,用它執行測試非常地簡單方便:


Kotlin Coroutines and Threads

Coroutine 跟 thread 的關係

Coroutine 通常可以理解成輕量化的 thread,但實際運作還是需要被排程到作業系統層級的 thread,然後再被排程到某個 CPU來執行:

相對於 thread,使用 coroutine 的好處是, coroutine 之間的切換快速,需要耗用的系統資源也比較小。

Coroutine 如何排程?

Kotlin 的 coroutine 會被分派到那個 thread 是由呼叫 coroutine builder 時提供的 CoroutineContext 參數來決定。

透過觀察這個範例的輸出,我們可以了解不同 CoroutineContext 的行為:

class CoroutineContextActivity : ConsoleActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        logThread("Begin onCreate")

        launch(UI) {
            logThread("Begin launch(UI)")
            delay(10_000)
            logThread("End launch(UI)")
        }

        launch(CommonPool) {
            logThread("Begin launch(CommonPool)")
            delay(10_000)
            logThread("End launch(CommonPool)")
        }

        launch(Unconfined) {
            logThread("Begin launch(Unconfined)")
            delay(10_000)
            logThread("End launch(Unconfined)")
        }

        launch(newSingleThreadContext("MyOwnThread")) {
            logThread("Begin launch(newSingleThreadContext)")
            delay(10_000)
            logThread("End launch(newSingleThreadContext)")
        }

        logThread("End onCreate")
    }

    fun logThread(msg: String) {
        println("$msg: ${Thread.currentThread().name}")
    }
}

結果:

1:48:50:866 (   1) Begin onCreate: main
1:48:50:937 (   1) Begin launch(Unconfined): main
1:48:50:938 (1719) Begin launch(CommonPool): ForkJoinPool.commonPool-worker-2
1:48:50:967 (   1) End onCreate: main
1:48:50:968 (1721) Begin launch(newSingleThreadContext): MyOwnThread
1:48:51:015 (   1) Begin launch(UI): main
1:49:00:971 (1721) End launch(newSingleThreadContext): MyOwnThread
1:49:00:973 (1720) End launch(Unconfined): kotlinx.coroutines.DefaultExecutor
1:49:00:980 (1724) End launch(CommonPool): ForkJoinPool.commonPool-worker-1
1:49:01:016 (   1) End launch(UI): main

心得:

  • Unconfined 在 suspend 之前會是由 calling thread 直接執行,suspend 之後則是用 DefaultExecutor thread 來執行。
  • CommonPool 會是由 common thread pool 裡面的 thread 來執行,suspend 前後被分配的 thread 有可能不同。
  • UI 只能在 main thread 上執行,並且因為是依賴 Android Looper 來實現的,coroutine 會在 onCreate 結束後才被執行到。
  • newSingleThreadContext 會由新產生的 thread 來執行,因爲創建新的 thread 需要一點時間,coroutine 是在 onCreate 結束後才被呼叫到。

這樣的設計主要有兩個優點:一來提供彈性讓使用者根據使用情境決定要使用那些 thread,二來提供擴充性讓使用者可以設計不一樣的 thread dispatching strategy。

如果想要掌握更進階的 coroutine 用法,Coroutine context and dispatchers 是很不錯的使用指南。


Kotlin Coroutine API