机器人操作系统ROS:从入门到放弃(四) C++类,命名空间,模版,CMakeLists介绍

124
0
2020年10月4日 19时11分

本文作者:陈瓜瓜

 

由于下一讲要讲到怎么在类中pub和sub消息.那么考虑到有些同学对类不甚熟悉.我们稍微回顾一下.但关于类网上一查其实一大堆东西,而且都是从入门讲起.所以我这儿肯定不会重复书写那些内容.要介绍的几个东西,其实本来要用得好的话蛮复杂,我们只会涉及到皮毛,重心会放在和之前的代码比较,以了解之前我们之前三讲的很多语法为什么可以那么写.
比如上一讲的geometry_msgs::PoseStamped的对象msg包含成员变量header和pose,heaer包含成员变量stamp等,为什么我们就可以使用msg.header.stamp这种语法来获取类型为time的变量?
再比如std_msgs::Int8这种语法怎么来的,中间那个::表示什么意思,以及它前后的std_msgs和In8有什么区别.
再比如我们定义ROS publisher时

 

ros::Publisher chatter_pub = n.advertise<std_msgs::String>("chatter", 1000);

 

为什么通过<std_msgs::String>这种语法来定义要发送的对象?这三个比如分别涉及到类,命名空间和模版.对语法熟悉或者不想究其所以然的同学可以跳过这一章直接进入下一章的讲在类中pub和sub消息.这一讲假设大家对函数,参数,循环等最基本的C++的东西已经掌握了.如果这些不清楚那么用C++操作ROS确实不太合适哈哈.类(class)同样类的作用和意义我就不详细阐释了,网上一抓一大把,他们的基本意义大家可以上网搜索.简单来讲,定义了类之后我们可以创建它的对象.对类和其对象直接操作是c++最重要的东西之一.直接开始例子.打开一个terminal,输入下面的内容

 

mkdir ~/C++Test
cd C++Test
mkdir classTest
mkdir namespaceTest
mkdir templateTest

 

咱们创建一个叫C++Test的文件夹,再创建三个用于测试三种东西的子文件夹.之后,在classTest文件夹下创建一个叫classBasic.cpp的文件和一个叫CMakeLists.txt的文件.在classBasic.cpp中输入下面内容.

 

 

#include <iostream>

class poorPhd{
public:
    /*define constructor*/
    poorPhd(){
        std::cout<<"we create a poor phd class"<<std::endl;
    }

    /*public member variable*/
    int hairNumber = 100;

    /*public member function*/
    int getGirlFriendNumber(){
        return girlFriendNumber;
    }

private:
    /*private member variable*/
    int girlFriendNumber = 0;
};

int main(){
    /*define the object*/
    poorPhd phd;//will use constructor function 
 
    /*call the public memberfunction*/
    std::cout<<"girlFriendnNumber is "<<phd.getGirlFriendNumber()<<std::endl;

    /*change tha value of member variale*/
    phd.hairNumber = 101;

    /*call the member variable*/
    std::cout<<"hairNumber is "<<phd.hairNumber<<std::endl;

    /*define class pointer*/
    poorPhd *phdPointer;

    /*assign the pointer to an object*/
    phdPointer = &phd;

    /*call the member variable*/
    std::cout<<"use pointer, hair number is "<<phdPointer->hairNumber<<std::endl;
}

 

逐行解说.
1:#include<> 包含头文件,这样可以使用std::cout<<…std::endl;

2:class poorPhd 定义了一个叫poorPhd的类.类后跟这宗括号{}.宗括号中的内容为类的内容.

3:public 加冒号之后的内容,即为公有.公有范围内定义的函数为公有成员函数,变量为公有成员变量.

4:poorPhd(). 这个函数称为构造函数(constructor function).在类创建时,会自动调用.构造函数的名字和类的名字必须一样并且没有返回值.

5:int hairNumber = 100. 定义了一个int类型公有成员变量,赋值100.

6:int getGirlFriendNumber(). 定义了一个返回值为int的函数,该函数会返回私有成员变量girlFriendNumber.

7:private加冒号之后的内容,即为私有.私有范围内定义的函数为私有成员函数,变量为私有成员变量.

8: int girlFriendNumnber=0. 定义了一个int类型的私有成员变量girlFriendNumber并赋值为0

main函数中
9: poorPhd phd  创建了一个类的对象(object),名字叫phd.每一个类,要想实际被使用,都需要创建一个对象.对象会拥有之前我们在类中定义的所有东西.所谓拥有,即是可以调用他们.对象的数量是没有限制的,并且他们之间不会干扰.你还可以用类似方法创建一个名字加abc的对象,它也会拥有poorPhd这个类的全部东西.
对象在创建时,会自动调用构造函数.

10:std::cout....phd.getGirlFriendNumber()<<std::endl;
类对象调用成员函数或者成员变量的方法是对象名.成员公有成员可以在类的定义外使用这种方式直接调用,私有成员是不可以被直接调用的.所以如果我们使用phd.girlFriendNumber就会报错.因为在类外,不可以直接调用私有成员变量.那有时候我们仍然想看到或者修改私有成员变量怎么办呢?那么我们可以写类似于这个gerGirlFriend的公有成员函数.公有成员函数定义在类中,所以它可以使用私有成员变量,并把变量的值作为返回值,这样我们就得到可私有成员变量的值.
为什么要分私有公有呢?有时候我们写了一个类,并不想其中所有东西都被使用者使用,比如我们有了造车相关技术,所有这些技术和在一起,就是类.具体实现,就是我们造了一辆辆车子.每一辆车子就称为对象.每一辆车子都有相同的内容,但是他们互不干扰.我们只想用户了解刹车,油门等东西.并不想用户了解车子内部构造.那刹车油门在这儿就是公有,而车子内部构造就是私有.如果用户实在想获取内部构造,用户可以去汽车销售店了解些相关资料,销售店就相当于咱们写的那个get...函数接口,架起用户和类私有成员友谊的桥梁.当然,如果有些内容特别私密,我们并不想用户了解它的相关资料,就不写那个get...函数就行了.

  1. phd.hairNumber = 101;
    为公有成员变量赋值101.

12.std::cout<<...phd.hairNumber...
调用公有成员并print出来.

13.poorPhd *phdPointer 创建一个类的指针.类的指针被创建时不会调用构造函数.它需要指向一个对象.

14.phdPointer = &phd 刚才创建的对象的地址赋值给指针,这个指针就有了phd对象的所有内容.

  1. ...phdPointer->hairNumber... 类指针调用类的成员的唯一不同之处就是使用指针名->成员调用而不是对象名.成员调用.

和之前写的ROS代码的联系: 之前我们定义过std_msgs::Int8 msg,msg即是类Int8的对象.我们通过查看roswiki http://docs.ros.org/api/std_msgs/html/msg/Int8.html 得知Int8包含类型为int8的成员变量data,所以我们通过msg.data使用这个成员.

写好文件后退出保存,打开之前建立的CMakeLists.txt文件.输入以下内容.

 

project(class_test)

cmake_minimum_required(VERSION 2.8)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAG} -std=c++11 -Wall")

add_executable(classBasic classBasic.cpp)

 

这基本上算是一个最简单的CMakeLists.txt文件了.CMakeLists.txt是用来编译C++文件的.
第一行表明了项目名称.
第二行输入CMake使用的最小版本号,一般是2.8以及以上.
第三行设定编译器.使用c++11.虽然我们的项目没用到c++11但是考虑到如今c++已经被普遍使用了,所以最好加上.我们在ROS的CMakeLists里注释过这个内容add_compile_options(-std=c++11)达到的也是使用c++11编译的效果.
第四行指定要编译的文件.要编译的文件是classBasic.cpp,编译后的可执行文件名字叫classBasic.
写完上面的内容后,保存退出.
在terminal中cd 到classTest这个文件夹输入下面的内容

 

 

 

mkdir build
cd build
cmake ..
make

 

 

第一二行命令创建一个叫build的文件夹并进入
第三行命令使用是使用cmake命令并通过..表示使用上一个文件夹的CMakeLists.txt.执行这行命令之后我们写的CMakeLists就会产生一系列的文件在build中,其中一个是Makefile.其他的这里不作介绍
第四行命令是使用makefile.makefile的作用就是直接编译你在CMakeLists里设定好的文件了.
建立一个build文件夹不是必须的但是推荐,因为你看到build里有一系列编译CMakeLists.txt里产生的文件,你以后要删除或者修改他们会比较方便,不至于和其他文件混在一起.
执行完上面的命令后,你会看到多了一个叫classBasic的文件没有后缀,这就是我们的可执行二进制文件了.使用./classBasic执行后得到下面的输出

 

 

 

we create a poor phd class
girlFriendnNumber is 0
hairNumber is 101
use pointer, hair number is 101

 

 

请对应源代码一行行查看输出为何如此.上一章我们说过有这样一段话msgs_header.stamp调用stamp,stamp.sec调用sec得到epoch的时间,那么msgs_header.stamp.sec就可以获取当前的时间,秒为单位.写段话之前我们创建了Header的对象msg_header,并通过ros wiki知道了该对象包含数据成员stamp,stamp包含数据成员sec,然后我们我们可以用这种msg_header.stamp.sec来调用sec这个数据成员.这种数据之间看起来的连续性具体是怎么实现的呢?
咱们在之前创建的classTest文件夹下再创建一个新的文件叫 classBasic2.cpp.并输入下面的内容.

#include <iostream>

class poorPhd{
public:
    /*define constructor*/
    poorPhd(){
        std::cout<<"we create a poor phd class"<<std::endl;
    }

    /*public member variable*/
    int hairNumber = 100;

    /*public member function*/
    int getGirlFriendNumber(){
        return girlFriendNumber;
    }

private:
    /*private member variable*/
    int girlFriendNumber = 0;
};

class master1 {
public:
    /*define constructor*/
    master1(){
        std::cout<<"we create a master class"<<std::endl;
    }
    /*member variable*/
    poorPhd future;
};


int main(){
    /*define the object*/
    master1 mStudent1;

    /*use inheritance*/
    std::cout<<"hairNumber of master student 1 is "<<mStudent1.future.hairNumber<<std::endl;
}

 

 

poorPhd类和上一个文件完全一样,我们新添加了一个类叫master1.master1同样有一个构造函数.另外它有一个成员变量,这个成员变量是poorPhd类型的对象future.那么在main函数中,定义了master1的对象mStudent1.咱们就可以用mStudent1.future调用变量future,再由于future是poorPhd类型的变量,所以可以用future.hairNumber调用hairNumber.连在一起就可以通过定义msater1的对象却最终调用了poorPhd的成员变量了.
保存退出后,在CMakeLists.txt中添加下面的内容.

 

 

add_executable(classBasic2 classBasic2.cpp)

 

 

terminal中进入classTest/build文件加输入

 

cmake ..
make

 

 

这时候就多了一个二进制文件classBasic2,执行该二进制文件你会看到

 

 

we create a poor phd class
we create a master class
hairNumber of master student 1 is 100

 

 

从这个输入可以看出,创建master1的对象mStudent1的时候c++会首先初始化它的成员变量,所以咱们先得到的是create a poor phd class,之后再调用了构造函数.
类还有很多很多的内容,就靠大家自己取学习了,咱们这儿只是简单地介绍了和前面的代码联系的部分.

命名空间(namespace)

你肯定使用过命名空间,基本上每一个写c++的人都会用过using namespace std这条语句.这条语句代表使用命名空间std.达到的效果是,例如你要使用cout语句在屏幕上打印什么东西,如果没有std,你需要输入的是

std::cout<<"....."<<std::endl;

 

 

如果你使用了using namespace std这条语句,那么你就只需要下面的内容打印语句

 

 

cout<<"...."<<endl;

 

 

但是你如果没写过大型程序的话,可能没有机会自己写过命名空间.命名空间一般是用来避免重命名的.大型的库里面一般定义了很多类,无数的函数.不同的大型的库之间很可能会有函数甚至类的命名重复,这会造成很大的麻烦.
namespace的命名语法也很简单

 

 

 

namespace name{
    //内容
}

 

 

下面这个程序简单地展示了两个命名空间里定义相同名字的类,并分别使用两个类的简单程序.

 

#include <iostream>

/*define a phd namespace*/
namespace phd {

    /*define a student class in phd namespace*/
    class student{
    public:
        student(){
            std::cout<<"create a student class in phd namespace"<<std::endl;
        }
        int graduateYear = 5;
        int hairNumber   = 100;
    };
}

/*define a master namespace*/
namespace master{

    /*define a student class in master namespace*/
    class student{
    public:
        student(){
            std::cout<<"create a student class in master namespace"<<std::endl;
        }
        int graduateYear = 2;
        int hairNumber   = 10000;
    };
}

int main(){

    /*create an object of student class, in phd namespace*/
    phd::student     phdStudent;

    /*create an object of student class, in master namespace*/
    master::student  masterStudent;

    std::cout<<"phd normally graduate in "<<phdStudent.graduateYear<<" years"<<std::endl;

    std::cout<<"master normally graduate in "<<masterStudent.graduateYear<<" years"<<std::endl;
}

 

 

上面的这个程序定义了两个命名空间,一个叫phd,一个叫master,这两个命名空间拥有一个类,类名都叫student
定义命名空间中的类的对象的方法是命名空间名::类名 对象名::被称为作用域符号(scope resolution operator).在main函数中我们定义了phd命名空间下的student类的对象phdStudent和master命名空间下的类student的对象masterStudenrt. 后面的两行各自输出了成员变量graduateYear
在我们之前的ros程序中,遇到了两个命名空间,一个是std_msgs,另一个是geometry_msgsInt8, Float64等都是std_msgs这个命名空间下的类,PoseStamped等是geometry_msgs这个命名空间下的类.
回到上面的程序我们在定义完phd这个命名空间后,可以使用using namespace phd,这样在main函数中我们可以不使用phd::来定义一个phd下的student类的对象,直接student phdStudent即可.同样,如果我们添加using namespace master,我们也可以直接使用student masterStudent来定义msater命名空间下student类的对象.
但是如果在程序中同时添加了

 

using namespace phd;
using namespace master;

 

 

这时候你在main函数中写student object_name就肯定会报错.因为电脑无法知道你要使用的student类是属于哪个命名空间的.所以一般为了图方便,在我们确定没有类名会重复时,我们添加using namespace ...这一行在定义完头文件之后,这样我们就可以省去在定义类时一直使用namespace_name::类名这种格式命名.但是有些时候如果两个库很有可能有相同的类名,就不要使用using namespace ...,不然很有可能造成程序的误解.写好上面的程序后和咱们写classBasic.cpp的过程完全一样的步骤,创建CMakeLists.txt和一个build文件夹进行编译.可能有的读者会问那如果命名空间的名字都重复了呢?你就删掉其中一个程序把 = = ….
同样,命名空间有的是学问,有兴趣的同学自行研究.

模版(Template)

模版这个东西,你如果是c++的使用者,那必定也接触过.为什么这么说呢?当你定义一个std::vector的时候,你就已经使用了模版了.但是你可能没自己写过模版(这种情况好像和namespace有点相似).
模版是为了避免重复定义同样功能的函数而开发的.
打个比方,你现在要实现平方一个数的函数.很简单,类似于下面这样

#include <iostream>

int square(int a){
    return a*a;
}

int main(){
   double x = 5.3;
   std::cout<<"the square of "<<x <<" is "<<square(x)<<std::endl;
}

 

 

这个程序有个很明显的缺点,编写函数或者使用变量时,都必须先指定类型,由于c++函数形参类型和返回值已经指定为int类型了,你只能传int类型进去,如果传double类型的变量进去,变量会被强制转换截断为int类型.而且只能return整型的变量.所以你只能得到25.
基本的解决方法是函数的重载,即我可以命名相同的函数但是变量类型或者个数不同以实现对不同输入的处理.类似于下面这样

 

 

 

#include <iostream>

int square(int a){
    return a*a;
}

double suqare(double a ){
    return a*a;
}

int main(){
   double x = 5.3;
   std::cout<<"the square of "<<x <<" is "<<square(x)<<std::endl;
}

 

 

这样调用square(x)时会自动匹配形参相同的函数.我们可以得到5.3的平方.但是可以想象,如果我有很多不同类型的变量要传入,我就得写好多不同的除了变量类型不同,其他的一模一样的函数了!有没有一种方法,形参什么类型都是可以的呢?
模版应运而生.模版的定义方式是

 

 

template <typename T>

 

 

或者

 

 

template <class T>

 

 

 

定义完之后后面紧跟要实现的函数或者是类.这个class不是我们之前理解的那种class了.这儿的class和typename作用完全一样,表示定义了一个新的类型T.这个新的类型具体是什么不知道,要等我们具体使用时程序根据传入的类型自行判断.
咱们先上代码,实现数字平方相同的功能.

 

#include <iostream>

template <typename T>
T square(T a){
    return a*a;
}

int main(){
    double x = 5.3;
    std::cout<<"square of "<<x<<" is "<<square(x)<<std::endl;
}

 

 

 

现在你无论传什么类型的数据进去,都会得到它的平方.sqaure指定的函数形参和返回值类型都为T.可以这样理解,现在当我们传入一个double类型的变量时,T就会自动变成double,传入int时,T就自动变为int.
下面来一个稍微复杂一点的例子的.实现两个向量的相加(好像也不怎么复杂…). 向量在c++里是不能直接相加的.我们定义向量时要指定向量元素的类型.比如std::vector<int> astd::vector<double> b等.和上一个例子一样,为了避免传入重载函数,我们使用模版.代码如下

 

 

 

#include <iostream>
#include <vector>

template <typename T, typename U>
U addVector(T vec1, U vec2){
    
    U result;

    if(vec1.size()!=vec2.size()){
        std::cout<<"cannot add two vector, they must be the same length. Return a null vector"<<std::endl;
        return result;
    }

    for(int i = 0; i<vec1.size(); i++){
        result.push_back(vec1[i]+vec2[i]);
    }
    return result;
}

int main(){
    std::vector<int> vec1 = {1,2,3};
    std::vector<double> vec2 = {4.0,5.0,6.0};

    auto addVec = addVector(vec1,vec2);

    for(auto i:addVec)
        std::cout<<i<<",";

    std::cout<<std::endl;
}

 

 

我们的tempalte定义了两个类型,一个叫U,一个叫T.为什么要定义两个呢?因为前面说过模板定义的具体类型在使用时确定的,在主函数中我们要加两个vector,一个是int类型的,作为第一个参数传入addVector,那么T就会是std::vector<int>,而第二个参数是double类型的向量,作为第二个参数传入函数后U就会相当于std::vector<double>,函数返回的类型也是U.
程序主函数第三行使用了auto这个关键字.使用c++11编译才可使用auto.这个是很有用的关键字.auto会自动分配被它定义的对象的类型,根据赋值的变量的类型.addVector返回的是U,在这个程序里也就是std::vector<double>了.那么auto会自动让addVec称为dpuble类型的vector.
主函数第四行的for循环采用的是有别于我们常用的for循环的形式.

 

 

for(auto i:addVec)

 

 

 

其中i:addVec的作用是把addVec中的元素依次赋值给i,这就要求i的类型得和addVec中的元素的类型相同,不过有auto的帮助,我们也就不用管这么多了,把i的类型定义为auto,那么程序会自动让i成为addVec中要赋值给i的元素的类型,这儿也就是double了.
说了这么多,还没到我们最初想讲的,那就是类似于std::vector<int>和我们使用ros的时候定义的advertise<std_msgs::String>这种类型的语法是怎么来的?首先根据命名空间那儿的学习我们知道std肯定是代表命名空间的名字了,vector是一个类,而<int>则来源于模版.如果我们使用模版定义了一个类,则会出现类似的内容.还是用简单的square函数来举例.我们来建立一个简单的sqaure类.

#include <iostream>

template <typename T>
class square{
public:
    T a;
    /*constructor function will store _a*_a as public member a*/
    square(T _a){
        a = _a*_a;
    }
};


int main(){
    double x = 5.5;
    square<double> test(x);
    std::cout<<"the square of "<<x<<" is "<<test.a<<std::endl;
}

 

 

在声明了模版之后紧接着我们声明了一个类,类的公有成员函数是一个类型为T的值a.主函数中,在我们声明模版下定义的类的对象时,我们需要在<>之中表明T的类型.再这之后才能定义对象.即普通的类的对象的定义格式如下

 

 

类名 对象名(构造函数参数)

 

 

模版下的类的对象定义的格式就是

 

 

类名<模版变量类型> 对象名(构造函数参数)

 

 

main函数第二行的这种定义方法,就类似于我们std::vector<int> ABC这种定义方法了,后者多的不过是在命名空间下定义了模版.然后再在模版下定义类.

总结

这一讲我们粗略地涉及到c++中几个简单又庞杂的系统,类,命名空间和模版,在我们平常使用的语法中多多少少都出现过他们的影子.只是我们自己不经常定义罢了.学会使用他们对建立庞杂的代码系统很有帮助.我们还介绍了最简要的CMakeLists的需要包含的内容.下一讲咱们回到ROS.在这一讲的基础之上,讲解在ros的类中发布/接收消息

发表评论

后才能评论