增強現實的 abseil 庫(1)
驪山四顧,阿房一炬,當時奢侈今何處?只見草蕭疏,水縈紆。至今遺恨迷煙樹。列國周齊秦漢楚,贏,都變做了土;輸,都變做了土。
—— 山坡羊·驪山懷古 張養浩
緣起
abseil 是 google 開源的 C++通用庫,其目標是作為標準庫的補充。abseil 不但提供了標準庫沒有但很常用的功能,也對標準庫的一些功能進行了增強設計,使用 abseil 庫能使程序性能和開發效率都取得不錯的提升。
abseil 庫包含多個獨立的子庫。本篇主要是對abseil 的字元串處理子庫 libabsl_strings.a 的功能進行介紹。
abseil 的編譯與鏈接
abseil 官網上提供了使用Bazel 和 cmake 構建abseil 的方法,但在使用時,並未以常規鏈接庫的形式使用 abseil。為保證兼容性,這裡介紹以庫的形式使用abseil 。首先按照文檔使用 cmake 編譯:
cd abseil-cpp && mkdir build
cd build && cmake ..
make -j10
執行上述指令後,會在 build/absl 的各子目錄中生成靜態庫,頭文件也在這些文件中。
cmake 生成的 Makefile 中,並沒有 install 目標,因而無法執行 make install
,需要手動執行,這裡假設把 abseil 的頭文件要放到 /home/w/include/absl 下,庫文件放到 /home/w/lib64 下:
(cd path/abseil-cpp/build/absl/ && find . -name *.h -print -or -name *.inc -print | tar --create --files-from -) | (cd /home/w/include/absl && tar xvfp -)
cd path/abseil-cpp/build/absl/ && cp `find ./ -name libabsl_*.a` /home/w/lib64
這樣就把頭文件和庫文件準備好了。abseil 生成的庫文件眾多,在使用時為了方便,可以將所有庫文件都同時鏈接。這裡以scons 構建工具為例,SConstruct 內容如下:
srcs = Split(test.cc)
target = a.out
cpp_path= Split(/home/w/include)
libs = Split(pthread rt)
libs.append(Glob("/home/w/lib64/libabsl_*.a")) # 鏈接abseil所有的子庫
lib_path = Split(/home/w/lib64)
cc_flags = Split(-g -std=c++11)
cc = gcc
cxx = g++
link_flags = ["-Wl,--start-group"]
Program(target, srcs, CPPPATH=cpp_path, LIBS=libs, LIBPATH=lib_path, CCFLAGS=cc_flags, CC=cc, CXX=cxx, LINKFLAGS=link_flags)
注意鏈接時必須加上 -Wl,--start-group
選項,這是由於abseil 的眾多子庫中有些子庫是被其他庫依賴的,需要由編譯器幫忙解析依賴關係,否則會報告某些符號未定義。
當然,也可以把這些庫合併成一個庫來使用:
mkdir /tmp/abseil
cd /tmp/abseil
ls /home/w/lib64/libabsl_*.a | xargs -n1 ar x # 批量解壓
ar cru libabseil.a *.o // 打包成單一的庫
ranlib libabseil.a
cp libabseil.a /home/w/lib64/
這時 SConstruct 的內容就可以簡化為:
srcs = Split(test.cc)
target = a.out
cpp_path= Split(/home/w/include)
libs = Split(pthread rt abseil)
lib_path = Split(/home/w/lib64)
cc_flags = Split(-g -std=c++11)
cc = gcc
cxx = g++
Program(target, srcs, CPPPATH=cpp_path, LIBS=libs, LIBPATH=lib_path, CCFLAGS=cc_flags, CC=cc, CXX=cxx)
解決了abseil 庫的編譯和鏈接問題後,我們就可以開始了解庫的具體功能了。
string_view 介紹
string_view 是字元串的輕量級表示。在 C++ 中 std::string 是最常用的數據類型,在編程時如果不注意std::string 類型的臨時變數生成和隱含的數據拷貝,會給性能帶來很大的損失。如果我們對字元串的使用僅僅是讀取,並且在使用期間字元串並不會被銷毀,那麼我們可以把字元串 「賦值」 給 string_view 類型, string_view 並不會進行數據拷貝,其性能要大大優於std::string 類型。
string_view 內部實際上是使用了兩個字元指針來表示兩個指針之間連續的字元串。需要注意的是,string_view 並不會持有數據本身,因而在使用string_view 時,必須保證底層字元串數據的生存期大於 string_view 的生存期。在很多開源代碼中都能見到和string_view 類似的實現,可見其通用性。由於string_view 經常被使用,因而在 C++17 標準中已經將string_view 加入到了標準庫中。abseil 為 string_view提供了十分豐富的介面,其使用方式如下:
#include <iostream>
#include <string>
#include <absl/strings/string_view.h>
int main() {
std::string s = "Tired like a dog";
absl::string_view sv(s); // 用 std::string 初始化
const char *cs = "Hello world";
absl::string_view sv1(cs); // 用字元串指針初始化
absl::string_view sv2(cs + 6, 5); // 用字元串區間初始化,輸出 "world"
absl::string_view sv3(cs + 6, 8); // string_view 會自動判斷字元串結尾,不會溢出,同樣輸出 "world"
absl::string_view sv4;
sv4 = sv3; // string_view 之間可以直接賦值
std::cout << sv << std::endl;
std::cout << sv1 << std::endl;
std::cout << sv2 << std::endl;
std::cout << sv3 << std::endl;
std::cout << sv4 << std::endl;
std::cout << sv4[2] << std::endl; // 可以用方括弧獲取string_view 中的字元
// sv4[2] = i; // ERROR:不允許通過方括弧更改底層字元,因為string_view 不持有底層字元
return 0;
}
輸出:
Tired like a dog
Hello world
world
world
world
string_view 還可以通過迭代器來讀取字元串中的字元,string_view 提供了正向、反向、常量正向、常量反向等4類迭代器:
#include <iostream>
#include <string>
#include <absl/strings/str_join.h>
#include <absl/strings/string_view.h>
int main() {
std::string s = "Tired like a dog";
absl::string_view sv(s);
for (auto it = sv.begin(); it != sv.end(); ++it) { // 正向迭代器
std::cout << *it << std::endl;
//*it = h; // ERROR: 不允許通過string_view 迭代器更改指向的數據,因為string_view 並不持有數據
}
std::cout << "----------" << std::endl;
for (auto it = sv.rbegin(); it != sv.rend(); ++it) { // 反向迭代器
std::cout << *it << std::endl;
}
std::cout << "----------" << std::endl;
const absl::string_view csv(sv);
for (auto it = csv.cbegin(); it != csv.cend(); ++it) { // 常量正向迭代器
std::cout << *it << std::endl;
}
std::cout << "----------" << std::endl;
for (auto it = csv.crbegin(); it != csv.crend(); ++it) { // 常量反向迭代器
std::cout << *it << std::endl;
}
return 0;
}
還可以獲取 string_view 指向的區間的數據、長度、字元等:
#include <iostream>
#include <string>
#include <absl/strings/string_view.h>
int main() {
absl::string_view sv1;
if (sv1.empty()) std::cout << "sv1 is empty" << std::endl;
const char* cs = "Tired like a dog";
absl::string_view sv(cs + 6);
std::cout << sv.data() << std::endl; // 輸出 」like a dog",即string_view 指向的區間
std::cout << sv.length() << std::endl; // 輸出指向的區間長度
std::cout << sv.size() << std::endl; // 同length
std::cout << sv.front() << std::endl; // 輸出子區間首字元 l
std::cout << sv.back() << std::endl; // 輸出子區間尾字元 g
return 0;
}
string_view 還可以移除其前綴和後綴,只需要指定要移除的長度:
#include <iostream>
#include <string>
#include <absl/strings/string_view.h>
int main() {
const char* cs = "Tired like a dog";
absl::string_view sv(cs); // 用 std::string 初始化
sv.remove_prefix(6); // 移除 sv 中從頭開始的6個字元 "Tired ",底層字元串不變
std::cout << sv << std::endl; // 輸出 "like a dog"
sv.remove_suffix(4); // 移除 sv 中從結尾開始的4個字元 " dog",底層字元串不變
std::cout << sv << std::endl; // 輸出 "like a"
std::cout << cs << std::endl; // "Tired like a dog"
sv.remove_suffix(20); // ERROR: 超過當前 sv 長度,運行時報錯
return 0;
}
需要注意的是,這裡移除前綴和後綴並不會更改string_view 指向的底層數據,這二者只是移動了string_view 內部的頭部和尾部指針,並修改了 string_view 的長度,並未對底層數據作任何改動。
我們還能直接從string_view 中提取內容到字元數組中、獲取 string_view 的子字元串,並且能直接交換兩個string_view 類型變數的值:
#include <iostream>
#include <string>
#include <absl/strings/string_view.h>
int main() {
const char* cs = "Tired like a dog";
absl::string_view sv(cs); // 用 std::string 初始化
const char* cs1 = "hello world";
absl::string_view sv1(cs1);
absl::string_view sv2 = sv1.substr(6, 10); // 獲取子字元串,長度超過末尾會截止到末尾處
char carr[10] = { 0 };
sv1.copy(carr, 4, 6); // 從 sv1 的索引為6的位置開始複製 4個字元到 carr 中
sv1.copy(carr, 8, 6); // 從 sv1 的索引為6的位置開始複製 8 個字元到 carr 中,但由於超過末尾,只複製
// 到sv1 的末尾
//sv1.copy(carr, 4, 25); // ERROR: 指定的位置超出了 sv 末尾,執行時報錯
sv1.swap(sv); // 交換兩個 string_view 類型的變數,底層數據不變
return 0;
}
string_view 的比較
abseil 庫中為 string_view 類型提供了豐富的比較功能。string_view 的比較和字元串比較函數 strcmp 的行為比較類似,執行字典序比較。比較結果為小於時返回 -1, 等於時返回 0, 大於時返回 1。
#include <iostream>
#include <string>
#include <absl/strings/string_view.h>
int main() {
absl::string_view sv("hello");
absl::string_view sv1("world");
if (sv.compare(sv1) < 0) {
std::cout << "hello" << std::endl;
}
sv.compare(3, 2, sv1); // 比較從位置3開始的兩個字元子串與 sv1 的大小
sv.compare(3, 2, sv1, 1 3); // 比較 sv 和 sv1 的兩個子串,子串用位置和長度表示
sv.compare("hello"); // sv 與 const char* 型比較
sv.compare(3, 2, "hello"); // 將sv子串與 const char* 型比較
sv.compare(3, 2, "hello", 4); // 將sv子串與 const char* 型從頭開始的子串比較, 4 是長度
return 0;
}
string_view 的查找
string_view 的查找功能同樣十分強大,可以執行多類查找。查找函數通常返回匹配的位置,當沒有匹配時,會返回 absl::string_view::npos。查找示例如下:
#include <iostream>
#include <string>
#include <absl/strings/string_view.h>
int main() {
absl::string_view sv("hello wold");
absl::string_view sv1("world");
auto pos = sv.find(sv1); // 從 sv 開頭查找sv1 首次出現的位置
pos = sv.find(sv1, 3); // 從 sv 的第三個位置開始查找 sv1 首次出現的位置,注意位置從 0 開始
pos = sv.find("abc");
pos = sv.find("abc", 3);
if (pos == absl::string_view::npos) { // 查找不到返回 absl::string_view::npos
std::cout << "not found" << std::endl;
}
pos = sv.find(c, 3); // 從 sv 的第三個位置開始查找字元 c 首次出現的位置
pos = sv.rfind(sv1, 3); // 從 sv 的第三個位置開始查找字元 sv1 最後出現的位置,返回位置為 sv1 首字元匹配的位置
pos = sv.rfind("abc", 3); // 從 sv 的第三個位置開始查找字元串 "abc" 最後出現的位置
pos = sv.rfind(c, 3); // 從 sv 的第三個位置開始查找字元 c 最後出現的位置
absl::string_view charset("abcde");
pos = sv.find_first_of(charset, 3); // 從 sv 的第三個位置開始查找字符集charset 中的任意一個字元首次出現的位置
pos = sv.find_first_of("defg", 3); // 從 sv 的第三個位置開始查找字符集 "defg" 中的任意一個字元首次出現的位置
pos = sv.find_first_of(c, 3); // 從 sv 的第三個位置開始查找字元 c 首次出現的位置,和 find(c, 3) 一樣
pos = sv.find_last_of(charset, 3); // 從 sv 的第三個位置開始查找字符集charset 中的任意一個字元最後出現的位置
pos = sv.find_last_of("defg", 3); // 從 sv 的第三個位置開始查找字符集 "defg" 中的任意一個字元最後出現的位置
pos = sv.find_last_of(c, 3); // 從 sv 的第三個位置開始查找字元 c 最後出現的位置,和 rfind(c, 3) 一樣
pos = sv.find_first_not_of(charset, 3); // 從 sv 的第三個位置開始查找不在字符集charset 中的任意一個字元首次出現的位置
pos = sv.find_first_not_of("defg", 3); // 從 sv 的第三個位置開始查找不在字符集 "defg" 中的任意一個字元首次出現的位置
pos = sv.find_first_not_of(c, 3); // 從 sv 的第三個位置開始查找不是字元 c 的任意字元首次出現的位置
pos = sv.find_last_not_of(charset, 3); // 從 sv 的第三個位置開始查找不在字符集charset 中的任意一個字元最後出現的位置
pos = sv.find_last_not_of("defg", 3); // 從 sv 的第三個位置開始查找不在字符集 "defg" 中的任意一個字元最後出現的位置
pos = sv.find_last_not_of(c, 3); // 從 sv 的第三個位置開始查找不是字元 c 的任意字元首次出現的位置
return 0;
}
string_view 傳值
由於 string_view 的實現足夠輕量,在作為函數形參時,string_view 類型可以直接 使用值傳遞,不需要使用引用傳遞。 string_view 類型使用值傳遞時,生成的代碼量會更少,效率也會較高:
#include <iostream>
#include <string>
#include <absl/strings/string_view.h>
void Show(absl::string_view sv) { // 使用值傳遞
std::cout << sv << std::endl;
}
int main() {
absl::string_view sv = "Tired";
Show(sv); // 使用值傳遞
std::string str = "like";
Show(str); // 實參可以直接使用 std::string 類型
Show("dog"); // 實參可以直接使用 const char* 類型
return 0;
}
string_view 的生命期
在這裡再強調一下 string_view 的生命期。由於 string_view 不持有底層數據,string_view 的有效生命期只能依賴於底層數據的生命期,當底層數據不存在時,string_view 也就失效了,此時再使用 string_view 很可能出錯。例如把函數的返回值賦值給 string_view 通常不是一個好的做法,因為這種情形下很容易出現生命期問題:
#include <iostream>
#include <string>
#include <absl/strings/string_view.h>
char* RetLocalStr() {
char cs[] = "hello";
return cs;
}
int main() {
absl::string_view sv = RetLocalStr();
for (auto i = 0; i < 10; ++i) {
std::cout << sv << std::endl;
}
return 0;
}
上述生命期問題並不是string_view 造成的,而是 RetLocalStr 本身的設計有問題。返回局部變數的指針或引用,通常會造成問題,基於這種易出錯情況,最好不要把 string_view 直接用於函數的返回值。
字元串連接 StrCat 和 StrAppend
在處理字元串的連接時,abseil 提供了 str_cat.h 頭文件。其中包含了高效處理字元串連接的功能。在進行字元串連接時,我們通常會使用 std::string 類的 』+『 操作符,這一操作符會 隱含的創建 std::string 變數,這就影響了字元串連接的效率,而 += 操作符不會創建臨時變數,提高了字元串連接的效率:
std::string s1 = "A string";
s1 = s1 + " another string"; // 先創建一個臨時變數,把 s1 複製進去,然後再把連接的部分複製進去,最後賦 // 值給 s1
s1 += " another string"; // += 更為高效,不用創建臨時變數
當一個表達式中有多個連接操作時, 『+= 操作符也無法避免臨時變數的創建:
std::string s1 = "A string";
std::string another = " and another string";
s1 += " and some other string" + another; // 還是需要創建臨時變數
為了減少臨時變數的創建從而提高效率,abseil 提供了 StrAppend 系列函數和 StrCat 系列函數來處理字元串的追加與連接。
由於 StrAppend 與 StrCat 系列函數採用了預分配策略減少了臨時變數的創建,這二者的效率會比 』+= 操作符的效率更高。
StrCat
StrCat 可以高效的從多個組件構建一個字元串,這些組件可以是C 字元串、 std::string 類型、absl::string_view 類型、整型、浮點型、布爾型等等:
#include <iostream>
#include <string>
#include <absl/strings/string_view.h>
#include <absl/strings/str_cat.h>
int main() {
absl::string_view sv = " hello";
std::string str = " world";
const char *cstr = " abc ";
int i = 55;
double f = 123.44678;
std::string res = absl::StrCat(sv, str, cstr, i, f);
std::cout << res << std::endl;
return 0;
}
# 輸出:
hello world abc 55123.447
注意布爾值的 false 和 true 會被轉換成字元串 「0」 和 「1」。 StrCat 處理浮點數時,會保留6位有效數字,注意不是6位小數,當浮點數小於等於 0.001 或大於等於 1000000.0 時,使用科學計數法表示:
#include <iostream>
#include <string>
#include <vector>
#include "absl/strings/str_cat.h"
int main() {
std::string s1;
double d = 288.71828333;
s1 = absl::StrCat("hello ", d);
std::cout << s1 << std::endl;
d = 3.141592653;
std::string s2 = absl::StrCat("hello ", d);
std::cout << s2 << std::endl;
d = 0.000005;
std::cout << absl::StrCat("hello ", d) << std::endl;
d = 1234567.0;
std::cout << absl::StrCat("hello ", d) << std::endl;
return 0;
}
// 輸出
hello 288.718 // 6 位有效數字
hello 3.14159 // 6 位有效數字
hello 5e-06 // 科學計數法
hello 1.23457e+06 //科學計數法
整數轉 16進位字元串
abseil 中的 StrCat 還支持將整數轉換成16進位的字元串,並且可以設置用0或空格來填充空位(pad) 從而達到固定的寬度:
#include <iostream>
#include <string>
#include <vector>
#include "absl/strings/str_cat.h"
int main() {
std::string s1;
int i = 255;
s1 = absl::StrCat("hello 0x", absl::Hex(i));
std::cout << s1 << std::endl;
s1 = absl::StrCat("hello 0x", absl::Hex(i, absl::kZeroPad4));
std::cout << s1 << std::endl;
s1 = absl::StrCat("hello 0x", absl::Hex(i, absl::kSpacePad4));
std::cout << s1 << std::endl;
return 0;
}
// 輸出:
hello 0xff
hello 0x00ff // 用 0 當作 pad,寬度總共為 4
hello 0x ff // 用空格當作 pad,寬度總共為 4
absl::Hex 的第二個參數是枚舉值, 當用0 填充時,支持 kNoPad, kZeroPad2 到 kZeroPad16 等16種方式。當使用空格填充時,支持 kSpacePad2 到 kSpacePad16 等15種方式。用 0 和用空格填充都是把字元串擴展到對應的寬度。
不適合 StrCat 的場景
abseil 提供 StrCat 是為了加速字元串的拼接速度,但如果使用不當,StrCat 的速度優勢就無法體現出來,特別是在進行字元串追加時,不應該使用 StrCat, 下面的用法就會影響 StrCat 的拼接速度,而應該使用 StrAppend:
str.append(absl::StrCat(...)) // 應該用 StrAppend 來實現對 str 的追加
str += absl::StrCat(...)
str = absl::StrCat(str, ...) // 字元串本身的追加,應該用 StrAppend
StrAppend
StrAppend 用於在字元串尾部追加新的內容,新的內容可以是 std::string 類型、absl::string_view 類型、C 字元串、整數、浮點數、bool 值等等,在追加整數和浮點數時,其轉換規則和 StrCat 一樣:
#include <iostream>
#include <string>
#include <vector>
#include "absl/strings/str_cat.h"
int main() {
std::string s1("hello ");
absl::StrAppend(&s1, "world");
std::cout << s1 << std::endl;
s1 = "hello ";
absl::string_view sv = "world";
absl::StrAppend(&s1, sv); // 追加 string_view 類型
std::cout << s1 << std::endl;
s1 = "hello ";
absl::StrAppend(&s1, 10000);
std::cout << s1 << std::endl;
s1 = "hello ";
absl::StrAppend(&s1, absl::Hex(255, absl::kZeroPad4)); // 用0填充至 4 位寬度
std::cout << s1 << std::endl;
s1 = "hello ";
absl::StrAppend(&s1, 314.1592653); // 保留 6 位有效數字
std::cout << s1 << std::endl;
s1 = "hello ";
absl::StrAppend(&s1, 0.0008); // 小於等於 0.005 的浮點數用科學計數法
std::cout << s1 << std::endl;
s1 = "hello ";
absl::StrAppend(&s1, 1000000.0); // 大於等於 1000000 的浮點數用科學計數法
std::cout << s1 << std::endl;
s1 = "hello ";
absl::StrAppend(&s1, 1000000.0, " world", " abc"); // 一次追加多項內容
std::cout << s1 << std::endl;
return 0;
}
// 輸出
hello world
hello world
hello 10000
hello 00ff
hello 314.159
hello 0.0008
hello 1e+06
hello 1e+06 world abc
不適合 StrAppend的場景
使用 StrAppend 時,追加的字元串內容不能是字元串本身的引用、或其子串的引用,例如如下用法都是錯誤的:
std::string s = "foo";
StrAppend(&s, s); // 追加字元串本身,結果未定義
std::string s = "foobar";
absl::string_view p = s;
StrAppend(&s, p); // p 不持有底層數據,還是相當於追加字元串本身的內容,結果未定義
如果想追加字元串本身或其子串,只能用std::string 類型的 += 操作符,或先將字元串複製到另外一個std::string 類型的變數中,之後再用 StrAppend。
結語
abseil 的功能十分強大,本篇介紹了其中 strings 庫 string_view 數據類型、StrCat 字元串拼接和 StrAppend 字元串追加等功能,關於字元串處理的更多功能將在下篇介紹。
使用開源庫,不但要知其然,還要知其所以然。從功能和實現層面全面的了解一個庫,會收穫很多,因而後續將會對abseil 的實現原理進行分析。
人生苦短,希望能讀到更多的優秀代碼,品味其中奇妙的思想。
我就是我,疾馳中的企鵝。
我就是我,不一樣的焰火。
推薦閱讀: