学习C++经验分享
本文章仅作为个人学习经验分享,若有错误可在评论指出,我们一起讨论。
学习C++从Cmake开始
为什么要从学习Cmake开始来学习c++呢?这是因为学习CMake有助于更轻松地管理复杂的C++项目,包括跨平台构建、模块化管理、自动化构建和第三方库集成,总而言之就是如果你要参与c++的大型项目,学习Cmake是一个近乎必要的过程。
以下是我对学习Cmake流程的一些经验和理解。
Cmake开发环境构建
使用Ubantu 22.04安装wsl2远程虚拟平台(linux系统),然后再使用linux系统开发环境打开VS Code,在VS Code中进行程序的编写与编译运行更加的方便。接着安装conda虚拟环境,然后不要使用基础conda环境(base)进行操作,建议创建一个新的虚拟环境并激活此虚拟环境进行操作。
Cmake基础学习
我在这里分享一下本人在Cmake基础学习中的方法,其实很简单,就是看B站视频,我在这里分享出来:
Cmake入门学习,可帮助你了解Cmake基本语法和操作
现代Cmake高级教程,目前都是使用现代Cmake进行编译
Cmake引入外部库须知(blog)
Cmake技巧分享
使用Ninja构建
使用Ninja进行编译的速度大大超过使用Cmake的默认编译器Makefile,因此我推荐使用Ninja构建系统。具体操作为修改你的~/.bashrc配置文件,在其末尾添加 export CMAKE_GENERATOR=Ninja
命令并保存,再于命令行中调用指令source ~/.bashrc
使其生效。
使用EXTERNALPROJECT_ADD指令避免重复构建
当我们使用ADD_SUBDIRECTORY命令对外部库进行编译时,可能会出现重复构建,也就是当这个外部库已经被编译完成之后在其他项目中将其使用ADD_SUBDIRECTORY仍然会触发编译库的命令,这就会出现重复构建。在大型项目中,一般需要引入与编译的外部库会很多,每一次编译的时间都不算短,因此为了降低调试与编译的时间成本,我们可以使用EXTERNALPROJECT_ADD这个指令来替代ADD_SUBDIRECTORY实现对外部库的编译,具体操作就是将ADD_SUBDIRECTORY的指令替换为:
include(ExternalProject)
ExternalProject_Add( hello #外部项目名字
SOURCE_DIR ${EXTERNAL_LIB_DIR} #调用外部项目的构建,EXTERNAL_LIB_DIR为外部项目的根目录
INSTALL_COMMAND "" # 禁止安装步骤
BINARY_DIR ${EXTERNAL_LIB_DIR}/build #指定构建目录以完成对外部库的构建
BUILD_ALWAYS TRUE #打开构建命令,避免外部库发生变化而不启动编译命令的情况发生
)
从难到易的C++语言学习
此学习路径适合具有一定编程基础的伙伴学习,最好是具有C++,C或者python语言的学习基础,否则不推荐此学习路径。
在这一过程中,我们将使用具体的示例来学习C++,即使用较为复杂的语句来实现一些特定的操作或实现程序编译与执行的优化过程,也就是通过这些较复杂较抽象的示例来对C++进行由点到面的学习。
在这一部分,我们将寻找网上的具体代码,通过对其进行细致的分析,了解各个关键词、语句的作用,从而提高自身对C++语言的理解能力,也提高了自身通过C++编程解决具体问题的能力。
Expression Templates(表达式模板)的学习
表达式模板,也被称为Expression Templates,是C++中的一种技术,用于实现高效的表达式计算和代码优化。它的核心思想是通过重载运算符和模板元编程来构建表达式对象,而不是立即计算表达式结果。这种延迟计算的方式可以避免不必要的中间结果的创建和拷贝,从而提高表达式的效率。
通过使用表达式模板,可以在不牺牲代码可读性的情况下实现高性能的数值计算。它在数值计算、线性代数、科学计算等领域中得到了广泛应用,使得C++可以接近使用特定领域专用语言的性能
接下来我们将以使用表达式模板实现矩阵加法为例,简单了解与学习表达式模板所体现的模板元编程的内涵,并在此过程的学习中更近一步的理解与运用C++关键字及语句。
矩阵加法问题描述
假如我们拥有三个矩阵,分别以a,b,c来命名,若我们要进行这样的计算:X=a+b+c,若我们只是简单的依靠重构矩阵加号运算符并返回结果矩阵的方式来进行计算像这样:
Matrix operator+(const Matrix &other) const {
std::vector<std::vector<int>> result;
for (std::size_t i = 0; i < data.size(); ++i) {
std::vector<int> row;
for (std::size_t j = 0; j < data[i].size(); ++j) {
row.push_back(data[i][j] + other.data[i][j]);
}
result.push_back(row);
}
return Matrix(result);
}
则计算过程会产生临时对象 t = a + b,然后才会进行 X = t + c 的操作,那么也就产生了临时对象t。此临时对象的产生对我们想要的结果并无任何益处,反而会损耗一部分性能。因此我们的目标应该是避免这个临时对象的产生,而直接执行计算 X = a + b + c 这样的操作。显然我们可以简单地通过直接定义矩阵加法的含义为这样X[i][j] = a[i][j]+b[i][j]+c[i][j]
, 以此来避免临时对象的产生。但这样做的话,则必须传入三个参数才能进行计算,其只适用于进行 X = a + b + c 这样的计算而不适用于 X = a + b 这样的计算,这会导致代码的灵活性与可读性降低。
表达式模板进行矩阵加法的过程描述
从而我们选择采用表达式模板的方法来对 X = a + b + c 进行计算,表达式模板的内涵在于,在进行 = 号赋值操作之前不会进行矩阵中元素层面实际的计算,而是将临时过程封装成一个表达式,在最后进行赋值操作时再将表达式解开以进行实际元素的相加。其对于 X = a + b + c 这样的实际进行到元素计算的操作流程实际如下X[i][j] = Expression(Expression<a,b>,c[i][j]) = Expression<a,b> + c[i][j] = a[i][j] + b[i][j] + c[i][j]
,(在下面的示例中,Expression类型实际就是MatrixSum类型)z通过表达式将中间过程进行封装,直到赋值操作时再进行实际元素层面的计算,这样就避免了临时对象的产生,同时也兼顾代码的灵活性,也提高了性能。
实现以表达式模板进行矩阵加法的具体编程过程
我们整个过程需要定义三个类,首先是MatrixExpr模板基类,用以表示Matrix矩阵类和MatrixSum矩阵加法表达式类;然后是Matrix类,用于初始化矩阵以及实现对矩阵元素的访问与修改以及赋值操作;最后是MatrixSum类,此类即为表达式类,拥有两个模板参数可以接受Matrix与MatrixSum类型的参数,可通过重构加号运算符用于封装矩阵类和表达式类,将其表示为一个表达式。
模板基类MatrixExpr
#include <iostream>
#include <cassert>
#include <vector>
template<typename E>
class MatrixExpr{
public:
double operator()(size_t i,size_t j) const { return static_cast<const E&>(*this)(i,j);}
size_t rows() const {return static_cast<const E&>(*this).rows();}
size_t cols() const {return static_cast<const E&>(*this).cols();}
};
这里是对模板基类MatrixExpr的定义,其必须定义()运算符用于解构表达式,以及定义rows()和cols()函数用于返回矩阵的行数和列数,用以确保相加矩阵的行数和列数是相等的。同时使用模板参数typename使其能够接受不同类型的参数,在这个示例中,主要是能够接受Matrix和MatrixSum两种类型。
模板类Matrix
class Matrix : public MatrixExpr<Matrix>{ //exact group to creat array and rebuild =
std::vector<double> data; // 使用一维数组存储数据
size_t n_rows;
size_t n_cols;
public: //to quote
// 新增的索引转换逻辑
size_t index(size_t i, size_t j) const { return i * cols() + j;}
Matrix(size_t rows,size_t cols) : data(rows * cols, 0.0), n_rows(rows), n_cols(cols) {}
double operator()(size_t i ,size_t j) const {return data[index(i,j)];} // 通过索引转换访问元素
double& operator()(size_t i ,size_t j) {return data[index(i,j)];} // 通过索引转换访问元素
size_t rows() const {return n_rows;}
size_t cols() const {return n_cols;}
// 其他成员函数和操作符重载不需要修改
template <typename E>
Matrix& operator=(const MatrixExpr<E> &expr){
assert(rows() == expr.rows() && cols() == expr.cols());
for (size_t i = 0; i < expr.rows(); i++){
for(size_t j = 0; j < expr.cols(); j++){
data[index(i, j)] = expr(i, j); // 使用索引转换逻辑访问一维数组元素
//use the expr(i,j) to unpack the array expression
//MatrixExpr<E>模板类好处在于可以定义MatrixSum和Matrix两种类型
//但同样的,在其还未解构的时候,这里用到的.rows()和.cols()以及()运算符需要在其内公共声明
//并调用其具体表达类的函数以及重构的()运算符
}
}
return *this;
}
};
使用一维数组存储数据,其在内存中是连续存储的,这样可以提高数据的访问效率。在Matrix类中具体实现了()运算符从而能够使用()代替[]实现元素层面的访问与计算,以及重构了=运算符,并且通过index函数将一维数组的索引转换为我们所熟悉的二维索引。
表达式类MatrixSum
template <typename E1, typename E2>
class MatrixSum : public MatrixExpr<MatrixSum<E1, E2>> {
// 使用 std::conditional 判断是否需要保存副本
using E1_ref = typename std::conditional<
std::is_lvalue_reference<E1>::value,
E1,
typename std::decay<E1>::type>::type;
using E2_ref = typename std::conditional<
std::is_lvalue_reference<E2>::value,
E2,
typename std::decay<E2>::type>::type;
//当 E1 或 E2 是一个左值引用时(例如,一个已经存在的对象的引用),我们仅保存一个指向该对象的引用。
//当 E1 或 E2 不是左值引用时(例如,一个临时右值对象),我们保存该类型的副本
E1_ref u;
E2_ref v;
public:
MatrixSum(const E1& u,const E2& v) : u(u),v(v){}
//size_t index(size_t i, size_t j) const { return i * cols() + j; } //只需要Matrix类中的index符号即可
double operator()(size_t i,size_t j) const {return u(i,j)+v(i,j);} //this is the key of expression templates!
size_t rows() const {return u.rows();}
size_t cols() const {return u.cols();}
};
表达式类通过两个模板参数,可以接受不同类型的参数从而完成表达式的封装。通过重载其()操作符返回两个类型通过()运算符的相加,通过MatrixSum类和Matrix类中的定义来看,我们不难发现,若是对MatrixSum类型使用()运算符,则其会返回其中包含的两个类型并且也使用()运算符,当其返回的类型是Matrix类型,则直接返回具体元素值参与赋值运算,当其返回的类型是MatrixSum类型,则继续返回两个其中包含的类型并且也使用()运算符,直至最后,会将此表达式全部解构为对Matrix类型使用()运算符,从而实现实际矩阵元素层面的相加与赋值。
重构矩阵加法运算符
template <typename E1,typename E2>
MatrixSum<E1,E2> operator+(const MatrixExpr<E1>& u,const MatrixExpr<E2>& v){
return MatrixSum<E1,E2>(*static_cast<const E1*>(&u),*static_cast<const E2*>(&v));
}
通过返回一个MatrixSum<E1,E2>矩阵表达式对象,实现了对计算中间过程的封装,直到=赋值操作时再对此表达式封装进行解构并进行实际矩阵元素层面的计算,从而避免了临时对象的产生,提高了性能。
设置打印矩阵函数用于验证
void printMatrix(const Matrix& m){
for(size_t i=0;i<m.rows();i++){
for(size_t j=0;j<m.cols();j++){
std::cout<<m(i,j)<<' ';
}
std::cout<<std::endl; //cout \n
}
}
主程序
int main(){
Matrix a(2,3),b(2,3),c(2,3);
Matrix result(2,3);
for(size_t i=0;i<a.rows();i++){
for(size_t j=0;j<a.cols();j++){
a(i,j) = 1.0*(i+1);
b(i,j) = 2.0*(j+1);
c(i,j) = 3.0*(i+j+1);
}
}
result = a + b + c ;
printMatrix(a);
printMatrix(b);
printMatrix(c);
printMatrix(result);
return 0;
}
直接使用result = a + b + c 即会返回一个Matrix类型的矩阵变量,并且在此过程中不会产生Matrix类型的临时矩阵对象,而是通过将其封装后再解构,从而实现了实际矩阵元素层面的加法,从而避免了临时对象的产生。通过打印矩阵我们可以验证其操作的正确性。若想印证其性能确实有所提高,可以通过chrono库来计算此部分运行所耗时长,并设定一个未使用表达式模板的矩阵类直接进行同样的加法操作并计算这一部分所耗时长,将两部分做对比便可发现使用表达式模板确实计算速度更快,所耗时长更短。(若对比不明显,可将两个部分均放入循环中多次运行计算总时长用以对比)
总结
使用表达式模板需要构建至少三个类,一个类是模板基类,用以表示其他类,一个是具体的矩阵类或函数类,并定义用以实现最后一个过程的具体的操作(如重构=运算符),一个是表达式类,用以封装与表达具体的类别,能够将中间过程存储起来至最后一个过程再进行实际的运算,从而避免了临时对象的产生。
讲到这,你可能会发现这其实也产生了表达式类作为中间对象,但实际上表达式类相对于具体的矩阵类或函数类并不占用多少内存,因为多数情况下其存储的只是是对于具体的矩阵类或函数类的引用。(为什么说是多数情况而不是所有情况呢?因为当要计算的数据为可能在最终计算前被销毁的临时对象时,我们会保存其副本以防止出错。而且事实上,若是这种情况,使用表达式模板进行运算的性能也会得到提高,因为这种情况下直接使用具体矩阵类进行矩阵的运算而不使用表达式模板也会形成对原始数据的副本才能计算,但其还会额外产生临时对象,因此一般来说,使用表达式模板进行矩阵运算之类的运算能够提高性能)
在C++程序中依据不同配置文件实现对外部库的编译并调用其库函数
问题描述
通过改动配置文件来让C++程序执行对指定外部库的编译以及调用外部库的指定函数,并且能够实现在命令台调用可执行文件的时候可以加后缀指定读取某一个配置文件以实现对应的功能。
实现思路
手写一个config1.txt文件与config2.txt文件并将其放和主程序文件同一目录下,然后通过特定的读取方式来实现读取,再使用相应方法对指定外部库进行读取并调用相应函数。
具体过程示例
引入所需系统库
#include <iostream>
#include <fstream>
#include <string>
#include <dlfcn.h>
#include <cstdlib>
设定配置文件
此过程只是一个简单的示例用以演示在c++程序内部编译外部库并调用外部库函数的过程,因此采用常用的txt文本文件来代表配置文件,通过指定的格式来指定相关信息。
config1.txt
[library] //表明这是引入外部库的配置文件
name=project2 //表明外部项目根目录为project2
build_command=cmake --build build //设定编译命令为 cmake --build build
function_to_call=HelloFunc //表明需要调用的函数为HelloFunc
libname=hello //表明库名字为hello
wherelib=/home/xiewenjun/dirtemp/local/local_lib //指定外部库文件目录
whereproject=/home/xiewenjun/dirtemp/project2 //指定外部库项目根目录
config2.txt
[library]
name=project5
build_command=cmake --build build
function_to_call=nihao
libname=nihao
wherelib=/home/xiewenjun/dirtemp/local/local_lib
whereproject=/home/xiewenjun/dirtemp/project5
读取配置文件
int main(int argc, char* argv[]){
std::string whereconfig = "/home/xiewenjun/dirtemp/rtq1/src/" + std::string(argv[1]) + ".txt";
std::ifstream configFile(whereconfig);
std::string line;
std::string libraryName;
std::string buildCommand;
std::string functionToCall;
std::string libname;
std::string wherelib;
std::string whereproject;
if (!configFile) {
std::cerr << "Unable to open config.txt" << std::endl;
return 1;
}
while (std::getline(configFile, line)) {
// 跳过区块标记
if (line[0] == '[') {
continue;
}
std::size_t pos = line.find('=');
if (pos != std::string::npos) {
std::string key = line.substr(0, pos);
std::string value = line.substr(pos + 1); //sub get str
if (key == "name") {
libraryName = value;
} else if (key == "build_command") {
buildCommand = value;
} else if (key == "function_to_call") {
functionToCall = value;
} else if (key == "libname") {
libname = value;
} else if (key == "wherelib") {
wherelib = value;
} else if (key == "whereproject") {
whereproject = value;
}
}
}
}
通过命令行参数 argv 和 argc 来实现在命令行指定对应配置文件进行读取。比如编译后生成的可执行文件为main,我们可以通过输入./main config1
使其读取config1.txt配置文件中的内容,输入./main config2
使其读取config2.txt配置文件中的内容,从而可以通过指定不同的配置文件以适用于不同情景下的需求。
编译外部库
因为此段程序是放在主函数main内部的,因此左边有一段空格。
std::string command = "cd " + whereproject+" &&" + buildCommand;
int result = std::system(command.c_str()); //return c style str
if (result != 0) {
std::cerr << "Build command failed with return code: " << result << std::endl;
return 1;
}
通过system()命令调用命令行命令来执行对外部库的编译。
读取库文件并调用指定函数
// 加载外部库 (这里假定项目名和库名相同)
std::string libPath = wherelib + "/lib" + libname + ".so";
void* libraryHandle = dlopen(libPath.c_str(), RTLD_GLOBAL | RTLD_LAZY);
if (!libraryHandle) {
std::cerr << "Error loading library: " << dlerror() << std::endl;
return 1;
}
// 获取函数指针
FunctionPtr funcPtr = (FunctionPtr) dlsym(libraryHandle, functionToCall.c_str()); //return c style str to command
//lsym 的返回值是一个 void* 类型,它表示函数的地址,
//通过将其强制转换为 FunctionPtr 类型,funcPtr 可以指向这个具体函数,从而可以像调用普通函数一样通过funcPtr()调用这个函数
const char* dlsym_error = dlerror();
if (dlsym_error) {
std::cerr << "Error locating symbol: " << dlsym_error << std::endl;
dlclose(libraryHandle);
return 1;
}
// 调用外部库的特定函数
(*funcPtr)(); //直接调用函数指针而不用*取函数对象也是可以的,即直接调用:funcPtr();对于void类型效果是一样的
// 关闭外部库
dlclose(libraryHandle);
总结
通过使用特定的读取文件的方式来读取配置文件中的信息,从而程序可以按照信息利用system()函数对指定外部库进行编译,然后再利用dlfcn库读取指定目录下的库文件并获取指定库函数指针,通过指定库函数指针我们可以实现对指定库函数的调用。