C++案例分析 – SQLite3 C API的C++封装(下)

转载请注明出处:https://cyc.wiki/index.php/2017/07/14/cpp-case-sqlite3-cpp-2/


永远不要说“精通C++”。


上一篇介绍了怎么用C++的特性优雅地封装SQLite3中的sqlite3_bind_*函数们。这一篇我们继续来看看怎么优雅地改写sqlite3_column_*函数们。

Github: SQLiteC++ (SQLiteCpp)

sqlite_column_*

在执行了SQL查询语句后,通过会使用sqlite3_step函数去逐步遍历查询结果的各行,而获取该行的各列数据的函数就是sqlite3_column_*。我们还是取上一篇的例子,假设数据表中第1列是int类型,第2列是double类型,第3列是字符串类型。

C API

const void *sqlite3_column_blob(sqlite3_stmt*, int iCol);
int sqlite3_column_bytes(sqlite3_stmt*, int iCol);
int sqlite3_column_bytes16(sqlite3_stmt*, int iCol);
double sqlite3_column_double(sqlite3_stmt*, int iCol);
int sqlite3_column_int(sqlite3_stmt*, int iCol);
sqlite3_int64 sqlite3_column_int64(sqlite3_stmt*, int iCol);
const unsigned char *sqlite3_column_text(sqlite3_stmt*, int iCol);
const void *sqlite3_column_text16(sqlite3_stmt*, int iCol);
int sqlite3_column_type(sqlite3_stmt*, int iCol);
sqlite3_value *sqlite3_column_value(sqlite3_stmt*, int iCol);

C API依然是这么简单粗暴,还是每一种类型的数据对应一个API的命名,但不同的是这一回所有函数的参数列表都一模一样,仅仅是返回值不同。还记得上一篇提到函数重载的实现本质上是由于参数列表被加进了函数签名,因此如果还想对不同的类型使用同样的函数就不能使用函数重载的方法了。

那怎么办呢?我们可以使用一个统一的中间的对象接收sqlite3_stmt*int iCol两个参数,然后利用类型转换操作符根据不同的类型调用不同的C API获取对应的数据,完成到目标类型的转换。

我不想记住函数的名称 – 类型转换操作符

我们来看SQLiteCpp中是怎么实现自动获取相应类型的结果的。
假设我们已经调用过sqlite3_step将游标移到了当前结果行,现在我们调用stmt.getColumn(index)获得一个Column对象:

Column Statement::getColumn(const int aIndex)
{
    checkRow();
    checkIndex(aIndex);

    // Share the Statement Object handle with the new Column created
    return Column(mStmtPtr, aIndex);
}

这个函数的内容很简单,除了一些合法检查,就是构造了一个Column对象并返回,Column对象是构造参数是stmt的指针和列序号。

Column::Column(Statement::Ptr& aStmtPtr, int aIndex) noexcept : // nothrow
    mStmtPtr(aStmtPtr),
    mIndex(aIndex)
{
}

而Column对象的构造函数更是干净,直接把传进来的参数在初始化列表里一存,留下两个空空的大括号。可能你会纳闷这机关究竟在哪呢?当然现在卖这个关子没什么意义,上面都已经剧透过了,就是在类型转换操作符里。

    /// Inline cast operator to int
    inline operator int() const
    {
        return getInt();
    }
    /// Inline cast operator to 32bits unsigned integer
    inline operator unsigned int() const
    {
        return getUInt();
    }
    /// Inline cast operator to 64bits integer
    inline operator long long() const
    {
        return getInt64();
    }
    /// Inline cast operator to double
    inline operator double() const
    {
        return getDouble();
    }
    /// Inline cast operator to char*
    inline operator const char*() const
    {
        return getText();
    }
    /// Inline cast operator to void*
    inline operator const void*() const
    {
        return getBlob();
    }

这里面的get函数相信大家也能猜到怎么写了:

// Return the integer value of the column specified by its index starting at 0
int Column::getInt() const noexcept // nothrow
{
    return sqlite3_column_int(mStmtPtr, mIndex);
}

// Return the unsigned integer value of the column specified by its index starting at 0
unsigned Column::getUInt() const noexcept // nothrow
{
    return static_cast<unsigned>(getInt64());
}

// Return the 64bits integer value of the column specified by its index starting at 0
long long Column::getInt64() const noexcept // nothrow
{
    return sqlite3_column_int64(mStmtPtr, mIndex);
}

// Return the double value of the column specified by its index starting at 0
double Column::getDouble() const noexcept // nothrow
{
    return sqlite3_column_double(mStmtPtr, mIndex);
}

// Return a pointer to the text value (NULL terminated string) of the column specified by its index starting at 0
const char* Column::getText(const char* apDefaultValue /* = "" */) const noexcept // nothrow
{
    const char* pText = reinterpret_cast<const char*>(sqlite3_column_text(mStmtPtr, mIndex));
    return (pText?pText:apDefaultValue);
}

// Return a pointer to the blob value (*not* NULL terminated) of the column specified by its index starting at 0
const void* Column::getBlob() const noexcept // nothrow
{
    return sqlite3_column_blob(mStmtPtr, mIndex);
}

我们最后来看我们的例子里要怎么获取各列的数据:

int a = stmt.getColumn(0);
double b = stmt.getColumn(1);
const char* c = stmt.getColumn(2);

好,总结一下这里发生的事情。
1. stmt.getColumn返回了一个临时构造Column对象,该对象持有获取该列数据所需的参数——stmt指针和列序号,但此时没有真正去获取。
2. 在a/b/c变量初始化过程中发生了隐式的类型转换,触发了int/double/const char*类型转换操作符。
3. 在类型转换操作符中调用了相应的C API获得该列数据,并作为操作符结果返回。

类型转换操作符也可以被显式类型转换触发,例如我想得到b的double值后转换成int,可以这么写:

int bi = static_cast<double>(stmt.getColumn(1)); // warning: double -> int implicit conversion

经过这么封装,不同类型的数据都可以用同一个函数getColumn进行获取。但值得注意的是执行什么类型的C API取决于类型转换的目标类型,显式的还好说,隐式的类型转换有时会忽略而搞错类型;或者是列序号填错导致想要转换的目标类型不是该列的类型,这些都很可能发生C API的失败。所以设计上必须能很好的给出错误信息,以供开发者调试。

为了解决列序号可能填错的问题,有没有更好的办法呢?在数据库应用中,我们通常会把数据表中的一行看作一个整体,以结构体或类的形式保存数据,那有没有办法把结果行直接转换成对应的结构体或类的实例呢?
有,解决的方式很像上一篇第二个问题的解决方法。

我想要参数序号能被自动地填入 – 变长参数模板 + 编译期整数序列

我们来看看SQLiteCpp中怎么让结果行数据直接存入对应的结构体。
getColumns函数定义如下:

    template<typename T, int N>
    T Statement::getColumns()
    {
        checkRow();
        checkIndex(N - 1);
        return getColumns<T>(std::make_integer_sequence<int, N>{});
    }

    // Helper function called by getColums<typename T, int N>
    template<typename T, const int... Is>
    T Statement::getColumns(const std::integer_sequence<int, Is...>)
    {
        return T(Column(mStmtPtr, Is)...);
    }

第一个函数是入口,进来先做了一下合法检查,然后调用第二个工具函数。
上一篇讲过了std::integer_sequence和它的一些推导,可以知道这里传入的参数

std::make_integer_sequence<int, N>

等同于

std::integer_sequence<int, /* a sequence 0, 1, 2, ..., N-1 */>

然后看这个std::integer_sequence传入第二个工具函数以后,帮助推导出了第二个模板参数包Is,即Is参数包的内容为推导为0, 1, 2, …, N-1。
这样,展开参数包Is的语句

return T(Column(mStmtPtr, Is)...);

等同于

return T(Column(mStmtPtr, 0), Column(mStmtPtr, 1), ..., Column(mStmtPtr, N-1));

接下来看我们具体要怎么使用getColumns获得结构体。

为了能接收一个结果行的数据,我们先定义一个结构体:

struct Row {
    int a;
    double b;
    const char* c;
    Row(int a, double b, const char* c) : a(a), b(b), c(c) {}
}

然后调用getColumns,指定模板参数为接收结果的结构体Row和列数3:

auto row = stmt.getColumns<Row, 3>();

根据上面的分析,工具函数中返回的Row对象等同于

return Row(Column(mStmtPtr, 0), Column(mStmtPtr, 1), Column(mStmtPtr, 2));

这里会去调用Row的构造函数,并把每个Column对象隐式转换成相应的参数类型,在隐式转换过程中调用C API获取数据。

参考资料:
Parameter Pack
Integer Sequence


这个案例分析就告一段落,总结一下这里解决问题的思路:
1. 要灵活处理不同的参数类型,可以考虑函数重载或者函数模板。
2. 要灵活处理不同的返回类型,可以考虑类型转换操作符。
3. 要灵活处理不同长度的参数,可以考虑变长参数模板(C++11特性),必要时使用编译期整数序列(C++14特性)作为辅助。


2017.7.18 更新:

之前在分析getColumns函数的时候举到一个例子,通过定义结构体

struct Row {
    int a;
    double b;
    const char* c;
    Row(int a, double b, const char* c) : a(a), b(b), c(c) {}
}

来接收返回的结果行。

不知道大家有没有跟我一样的疑问:为什么这里需要声明一个构造函数呢?如何不声明会怎么样?
我试过把这行构造函数删去,会得到以下编译错误:
error C2440: '<function-style-cast>': cannot convert from 'initializer list' to 'Statement_getColumns_Test::TestBody::Test'
这个错误意味着,在调用

return T(Column(mStmtPtr, Is)...);

时,Column(mStmtPtr, Is)...,类型为initializer list,要先被转换成Test结构体,再调用T(T&&)移动构造函数。而由于不存在initializer list到Test结构体的转换方式,所以报错了。

那有没有办法不需要声明一个构造函数就实现initializer list的直接构造呢?
其实只要把T()改成T{}就可以了:

return T{Column(mStmtPtr, Is)...};

这样有两种用法:
1. 如果没有定义相应参数列表的构造函数,则是以默认initializer list初始化的方式构造结构体,初始化列表里的元素次序与结构体成员定义的次序保持一致。
2. 如果定义了相应参数列表的构造函数,则是以T{}的形式调用构造函数,与T()的行为是基本一致的。

以这个改进建议我向SQLiteCpp项目的作者提出了一个issue,并获得了作者的采纳。作者已经针对我的建议提交了一个fix

C++案例分析 – SQLite3 C API的C++封装(上)

转载请注明出处:https://cyc.wiki/index.php/2017/07/12/cpp-case-sqlite3-cpp-1/


永远不要说“精通C++”。


这其实是来源于我的一个工作项目,其中一个需求就是用C++将SQLite3原来的C API进行一下封装。当时封装做的也就那样,参考了一些别的实现,勉强能用,但是觉得C++封装后做出来的东西确实很优雅。刚好在Github上找到了类似的项目和实现方法,就拿出来解析一下。

Github: SQLiteC++ (SQLiteCpp)

主要拿SQLite3最常用的其中两个操作做例子:sqlite3_bind_*sqlite3_column_*

sqlite3_bind_*

sqlite3_bind_*的作用就是将对应位置的参数值绑定到预备查询语句字符串当中的“?”处。例如SELECT * FROM t WHERE c1=? AND c2=? AND c3=?,里面有三个参数需要填入值。这里我们假设列c1是int类型,列c2是double类型,列c3是字符串类型。

C API

int sqlite3_bind_blob(sqlite3_stmt*, int, const void*, int n, void(*)(void*));
int sqlite3_bind_blob64(sqlite3_stmt*, int, const void*, sqlite3_uint64,
                        void(*)(void*));
int sqlite3_bind_double(sqlite3_stmt*, int, double);
int sqlite3_bind_int(sqlite3_stmt*, int, int);
int sqlite3_bind_int64(sqlite3_stmt*, int, sqlite3_int64);
int sqlite3_bind_null(sqlite3_stmt*, int);
int sqlite3_bind_text(sqlite3_stmt*,int,const char*,int,void(*)(void*));
int sqlite3_bind_text16(sqlite3_stmt*, int, const void*, int, void(*)(void*));
int sqlite3_bind_text64(sqlite3_stmt*, int, const char*, sqlite3_uint64,
                         void(*)(void*), unsigned char encoding);
int sqlite3_bind_value(sqlite3_stmt*, int, const sqlite3_value*);
int sqlite3_bind_zeroblob(sqlite3_stmt*, int, int n);
int sqlite3_bind_zeroblob64(sqlite3_stmt*, int, sqlite3_uint64);

C API可以说是简单粗暴,每一种类型的参数有一个对应名字的API函数,前两个参数一样,第一个是语句指针,第二个是参数的序号,后面的参数就跟参数值有关。

于是填入参数需要下面的语句:

sqlite3_bind_int(stmt, 1, 10);
sqlite3_bind_double(stmt, 2, 20.2);
sqlite3_bind_text(stmt, 3, "30", -1, SQLITE_TRANSIENT);

看到这组API,也许你不想要记住函数的名称,并且想要参数序号能被自动地填入。好,那我们来看C++怎么帮你实现。

我不想记住函数的名称 – 函数重载

这应该是C++很基本的一个语言特性了,所以就不多介绍。这里只说下函数重载之所以能在C++中实现,根本原因是C++改变了函数签名的规则,将函数参数列表(不包括返回值)加入了函数签名中,所以不同函数参数列表的同名函数本质上就是不同的符号。C中没有这么做,符号基本上就是函数名本身。在C++中用extern “C”定义的函数,其实就是把函数参数列表从函数签名中扒掉,于是C的程序就可以找到extern “C”定义的函数。

SQLiteCpp做了如下封装:

// Bind an int value to a parameter "?", "?NNN", ":VVV", "@VVV" or "$VVV" in the SQL prepared statement
void Statement::bind(const int aIndex, const int aValue)
{
    const int ret = sqlite3_bind_int(mStmtPtr, aIndex, aValue);
    check(ret);
}

// Bind a 32bits unsigned int value to a parameter "?", "?NNN", ":VVV", "@VVV" or "$VVV" in the SQL prepared statement
void Statement::bind(const int aIndex, const unsigned aValue)
{
    const int ret = sqlite3_bind_int64(mStmtPtr, aIndex, aValue);
    check(ret);
}

// Bind a 64bits int value to a parameter "?", "?NNN", ":VVV", "@VVV" or "$VVV" in the SQL prepared statement
void Statement::bind(const int aIndex, const long long aValue)
{
    const int ret = sqlite3_bind_int64(mStmtPtr, aIndex, aValue);
    check(ret);
}

// Bind a double (64bits float) value to a parameter "?", "?NNN", ":VVV", "@VVV" or "$VVV" in the SQL prepared statement
void Statement::bind(const int aIndex, const double aValue)
{
    const int ret = sqlite3_bind_double(mStmtPtr, aIndex, aValue);
    check(ret);
}

// Bind a string value to a parameter "?", "?NNN", ":VVV", "@VVV" or "$VVV" in the SQL prepared statement
void Statement::bind(const int aIndex, const std::string& aValue)
{
    const int ret = sqlite3_bind_text(mStmtPtr, aIndex, aValue.c_str(),
                                      static_cast<int>(aValue.size()), SQLITE_TRANSIENT);
    check(ret);
}

// Bind a text value to a parameter "?", "?NNN", ":VVV", "@VVV" or "$VVV" in the SQL prepared statement
void Statement::bind(const int aIndex, const char* apValue)
{
    const int ret = sqlite3_bind_text(mStmtPtr, aIndex, apValue, -1, SQLITE_TRANSIENT);
    check(ret);
}

// Bind a binary blob value to a parameter "?", "?NNN", ":VVV", "@VVV" or "$VVV" in the SQL prepared statement
void Statement::bind(const int aIndex, const void* apValue, const int aSize)
{
    const int ret = sqlite3_bind_blob(mStmtPtr, aIndex, apValue, aSize, SQLITE_TRANSIENT);
    check(ret);
}

非常简单,就这样使用同样的函数名也能绑定不同类型的函数了。之前的实现代码变成了:

stmt.bind(1, 10);
stmt.bind(2, 20.2);
stmt.bind(3, "30");

我想要参数序号能被自动地填入 – 变长参数模板

变长参数模板(Variadic Template)是C++11引入的一个新特性,可以把模板玩得很花,也很实用。我们先来看下SQLiteCpp里的实现:

/// implementation detail for variadic bind.
namespace detail {
template<class F, class ...Args, std::size_t ... I>
inline void invoke_with_index(F&& f, std::integer_sequence<std::size_t, I...>, const Args& ...args)
{
    std::initializer_list<int> { (f(I+1, args), 0)... };
}

/// implementation detail for variadic bind.
template<class F, class ...Args>
inline void invoke_with_index(F&& f, const Args& ... args)
{
    invoke_with_index(std::forward<F>(f), std::index_sequence_for<Args...>(), args...);
}
}

template<class ...Args>
void bind(SQLite::Statement& s, const Args& ... args)
{
    static_assert(sizeof...(args) > 0, "please invoke bind with one or more args");

    auto f=[&s](std::size_t index, const auto& value)
    {
        s.bind(index, value);
    };
    detail::invoke_with_index(f, args...);
}

这个实现中还利用了C++14的编译期整数序列(std::integer_sequence)特性,免去了变长参数模板展开常用的递归模式。其实不使用C++14的特性,单用C++11的变长参数模板也能实现自动填写参数序号。

上面两个是工具函数,为最下面的bind函数服务。我们先来看bind函数:

template<class ...Args>
void bind(SQLite::Statement& s, const Args& ... args)
{
    static_assert(sizeof...(args) > 0, "please invoke bind with one or more args");

    auto f=[&s](std::size_t index, const auto& value)
    {
        s.bind(index, value);
    };
    detail::invoke_with_index(f, args...);
}

template<class... Args>声明了Args是一个类型模板的参数包,这个对应于传统的类型模板template<class T>声明T是一个类型模板的参数,在推导模板的时候类型模板参数包可以包含不定长度的模板类型。

const Args& ... args声明了参数args是一个函数参数包,类型为const Args&...,在调用的时候函数参数包可以对应不定长度的函数参数。

sizeof...运算符可以获得参数包args的参数个数。此处args后不带…,说明此处args以参数包形式出现。

f是一个无返回值的lambda表达式,代表着传入index和value后,调用s.bind(index, value)的一段逻辑。

detail::invoke_with_index(f, args...)调用工具函数,参数分别是f和args…,此处args以展开后的参数包形式出现。

接着我们看上面的第二个函数:

template<class F, class ...Args>
inline void invoke_with_index(F&& f, const Args& ... args)
{
    invoke_with_index(std::forward<F>(f), std::index_sequence_for<Args...>(), args...);
}

这还是一个带参数包的函数模板,F对应于将要传入的lambda的类型,Args跟之前一样是一个类型模板参数包。
接下来就是调用第一个工具函数。

第一个参数是将要传入的lambda,代表怎么处理这些个参数。std::forward完美转发,这是C++11右值的一个重要特性,可以单开一篇写,这里可以先忽略它。

第二个参数std::index_sequence_for<Args...>()用到了C++14的编译期整数序列特性。在C++14的头文件中有以下定义:

template<class T, T N>
using make_integer_sequence = std::integer_sequence<T, /* a sequence 0, 1, 2, ..., N-1 */ >;
template<std::size_t N>
using make_index_sequence = make_integer_sequence<std::size_t, N>;
template<class... T>
using index_sequence_for = std::make_index_sequence<sizeof...(T)>;

经过上面的推导,最终传进去的就是一个std::integer_sequence<std::size_t, 0, 1, 2, ..., N-1>,N为变长参数的个数。

第三个参数就是展开后的参数包args。

最后我们看第一个工具函数:

template<class F, class ...Args, std::size_t ... I>
inline void invoke_with_index(F&& f, std::integer_sequence<std::size_t, I...>, const Args& ...args)
{
    std::initializer_list<int> { (f(I+1, args), 0)... };
}

模板参数中,第一个为类型模板参数F,第二个为类型模板参数包Args,第三个为非类型模板参数包I,I对应于一组类型为std::size_t的数值。

函数参数中,第一个对应将要传入的lambda,第二个是编译期整数序列,其实就是上面传进来的0, 1, 2, …, N-1,用于推导中模板参数中的非类型模板参数包I,第三个是参数包args。

结合上面的分析,f是处理参数的逻辑,args是参数列表,I中含有各参数对应的序号,看起来万事大吉,只要展开调用就完事了。就像这样:

(f(I+1, args))...;

结果会收到一个编译错误,说I和args在这里必须要展开。可是我已经把整个表达式用…展开了啊?事情没有那么简单,参数包展开的位置是有严格的限制的,基本只在函数调用的参数列表里可以展开。但也还有一种情况,可以在初始化列表里的大括号里展开,并且这里还有一个好处,初始化列表里的大括号里展开能保证顺序执行。于是就写成这样:

std::initializer_list<int> { (f(I+1, args), 0)... };

这个意思是用初始化列表初始化一个匿名的std::initializer_list<int>(其实大括号初始化列表本身就是这个类型)。由于列表的元素必须是int类型,因此展开时用逗号表达式,先执行f(I+1, args),再把表达式的值替换成0。展开后的样子就是:

std::initializer_list<int> { (f(1, arg0), 0), (f(2, arg1), 0), ..., (f(N, argN-1), 0) };

顺序执行完所有逗号表达式后其实就是:

std::initializer_list<int> { 0, 0, ..., 0 };

这样既调用了f(1, arg0)到f(N, argN-1),又能符合初始化列表的语法和参数包展开的语法。
详细的讨论参考这个Stackoverflow帖子

最后之前例子的调用变成:

bind(stmt, 10, 20.2, "30");

是不是觉得世界怎么这么美好。

参考资料:
Parameter Pack
Integer Sequence