☰
ROS2类节点编程:从脚本式到面向对象,掌握Python与C++实现与继承
2026/10/24 4:26:14 网站建设 项目流程

写到Day 6,前面几天我们把ROS2的安装、工作空间、话题、服务、参数、动作都过了一遍。回头看看那些示例代码,基本都是“脚本式”写法——主函数里生成节点,回调函数往模块里一扔,再搞几个全局变量换数据。当时的目标是先跑起来,怎么简单怎么来,这没问题。可一旦你开始写真正上车的机器人逻辑,代码量奔着上千行走,这种写法会让你天天在理不清的回调里挣扎。所以今天开始,我们把面向对象这块底子补上:怎么用Python和C++把节点写成真正的类,继承在ROS2里到底该怎么用、能解决什么问题。

这一篇核心就三个:类节点为什么是正规姿势、两种语言的类节点到底怎么搭、继承和组合什么时候该用。读完之后,你能把之前那些脚本式节点重构为类节点,并且知道怎么写才能让后面的功能扩展不痛苦。老规矩,学累了记得多喝热水。

1. 为什么非要写成“类节点”

1.1 脚本式写法的三个痛点

先别急着写代码,我们来拆一下脚本式写法的问题。最直观的一点是状态共享。一个节点往往要同时处理多个话题的数据,比如订阅激光数据、订阅里程计数据,还要根据它们算出新的速度指令发出去。脚本式写法里,每个回调都是独立的模块级函数,两个回调之间想共享数据,只能靠全局变量。全局变量多到一定程度,命名、同步、调试全是麻烦,改一个变量的生命周期就想骂人。

第二个痛点是代码组织。一个稍大一点的节点,发布者、订阅者、服务端、客户端、定时器、参数声明,这些玩意全在函数里堆着,阅读体验极差。你很难一眼看出这个节点的输入是什么、输出是什么、内部有哪些状态。找个东西全靠Ctrl+F,维护成本肉眼可见地往上走。

第三个痛点是测试。脚本式节点基本没法做单元测试,你想验证某个回调函数对消息的处理逻辑,得先把节点搭起来把话题跑通,费时费力。如果节点是一个类,直接实例化一个对象,手动调用回调传个假消息进去,原地就能验证结果,这差别不是一点半点。

提示:如果只是验证话题通信、确认消息收发,脚本式写法完全够用,不用给自己上心理负担。但只要你打算让程序同时处理多个话题、多个状态,我建议尽早切到类节点。

1.2 类节点才是ROS2的“官方姿势”

ROS2官方的教程、示例、各种工具链,默认都是类节点。更关键的是,ROS2的组件机制要求节点必须写成类,还要是那种可以编译成共享库的类,这样多个节点才能在同一个进程里动态加载、零拷贝通信。生命周期节点也是建立在类继承之上的,它把节点生命周期分成未配置、未激活、激活、错误处理等几个状态,本质上就是一个Node子类。

所以学类节点,不只是为了“代码更好看”,它是在给后续的进阶功能铺路。你现在不把类节点的写法吃透,后面接触组件、生命周期、多机器人系统,会突然觉得跨度特别大。反过来说,把今天这点东西弄明白,后面那些高级特性就是水到渠成的事。

1.3 什么时候可以不用类

当然也有例外。比如你在命令行里临时测一个话题、验证某个算法的输出格式,写一个小脚本十几行搞定,那就别非搞一个类出来。这种一次性调试工具,脚本式写法反而省事。我的原则是:哪里需要长期维护、哪里要复用、哪里要和别的节点协作,哪里就用类。纯临时验证,脚本没有任何问题。

2. Python版类节点:从初始化到回调

2.1 最小可用的talker类拆解

先看一个最简洁的Python类节点,功能就是发布一个字符串消息,频率1秒一次。

import rclpy from rclpy.node import Node from std_msgs.msg import String class MinimalTalker(Node): def __init__(self, node_name: str = "minimal_talker"): super().__init__(node_name) self._publisher = self.create_publisher(String, "chatter", 10) self._counter = 0 self._timer = self.create_timer(1.0, self.timer_callback) def timer_callback(self): msg = String() msg.data = f"Hello ROS2 {self._counter}" self._publisher.publish(msg) self.get_logger().info("publishing: '%s'" % msg.data) self._counter += 1 def main(args=None): rclpy.init(args=args) node = MinimalTalker() rclpy.spin(node) rclpy.shutdown() if __name__ == "__main__": main()

把这个和脚本式对比一下,最核心的变化在于:发布者、定时器、计数器都成了self的成员属性,回调成了类的方法,状态通过self._counter在多次回调之间传递,再也不用靠全局变量。从外部看,这个类就是一个完整的节点单元:初始化完,扔给rclpy.spin(),它自己就会一直干活。

2.2 super().__init__到底做了什么

很多人写Python类节点的时候对super().__init__(node_name)这一行不太在意,觉得就是“调用父类构造函数”,但这句话背后做了不少事。Node的构造过程会创建一个底层的rcl节点句柄,这个句柄连接着rclpy的运行时环境。创建句柄时会校验你传入的节点名,这个节点名必须是合法的DNS风格命名,只有字母、数字、下划线,不能用斜杠、不能用空格,否则直接抛异常。

构造过程中还会把日志接口、参数接口、时钟、图事件接口、计时器接口全部挂载到这个节点上。你后面用的get_logger()、create_publisher()、declare_parameter(),全都依赖这个已经就绪的底层句柄。所以一个经验是:在__init__里尽可能把节点需要的东西都准备好,因为构造函数跑完之后,这个节点就该是可用的状态,而不是还得在外面补一堆初始化代码。

另外,super().__init__()里还可以传入自定义的context对象。默认情况下节点使用全局默认上下文,但如果你的程序里要创建多个相互独立的节点组,或者想控制节点的生命周期,那就需要显式传context。这个我们下一期讲多节点管理的时候再展开,今天先记住这个接口存在就行。

2.3 成员变量与回调绑定的几个细节

类节点里成员变量的命名,我建议统一用下划线开头,比如_publisher、_timer、_counter。这主要是提醒自己:这些是内部变量,外部不要去直接动它们。ROS2的节点对象在调试时会暴露很多属性和方法,内部变量用私有风格命名,能少很多误伤。

初始化顺序也值得注意。严格来说,super().__init__()必须先执行,之后再去创建发布者、定时器等资源。因为那些create_xxx方法都依赖于底层节点句柄已经就绪。我见过有人把super().__init__()写在成员赋值后面,结果一运行就报错,找半天才发现是顺序问题。

还有一个很容易忽略的点:定时器回调不一定是严格的定时。Python的定时器精度受执行器调度影响,回调如果执行时间过长,下一次触发会自动跳过。所以别在定时器回调里做重活,真的要做复杂计算,建议回调里只发消息,计算放别的线程或者单独的逻辑模块里。

2.4 一个进程跑多个类节点

类节点还有一个好处:一个Python进程里可以同时创建多个节点对象,用多线程执行器并行驱动它们。脚本式写法想在一个进程里跑多个节点会非常别扭,而类节点这事就简单很多。

import rclpy from rclpy.executors import MultiThreadedExecutor rclpy.init() executor = MultiThreadedExecutor() executor.add_node(MinimalTalker()) executor.add_node(MinimalListener()) executor.spin()

多节点并行的场景在机器人里太常见了,比如视觉节点和底盘控制节点写在同一个进程里,共享内存传递信息。类节点加多线程执行器,是ROS2里很标准的多节点形态。

3. C++版类节点:构造、智能指针与真回调

3.1 C++类节点骨架

C++版和Python版结构相似,但写法上有明显的语言差异。看一个最小的talker:

#include "rclcpp/rclcpp.hpp" #include "std_msgs/msg/string.hpp" class MinimalTalker : public rclcpp::Node { public: MinimalTalker() : Node("minimal_talker") { auto qos = rclcpp::QoS(10); publisher_ = this->create_publisher<std_msgs::msg::String>("chatter", qos); timer_ = this->create_wall_timer( std::chrono::seconds(1), std::bind(&MinimalTalker::timer_callback, this)); counter_ = 0; } private: rclcpp::Publisher<std_msgs::msg::String>::SharedPtr publisher_; rclcpp::TimerBase::SharedPtr timer_; size_t counter_; void timer_callback() { auto msg = std_msgs::msg::String(); msg.data = "Hello ROS2 " + std::to_string(counter_); publisher_->publish(msg); RCLCPP_INFO(this->get_logger(), "publishing: '%s'", msg.data.c_str()); counter_++; } }; int main(int argc, char **argv) { rclcpp::init(argc, argv); auto node = std::make_shared<MinimalTalker>(); rclcpp::spin(node); rclcpp::shutdown(); return 0; }

注意几个关键点。类的基类并不是rclcpp::Node的定义,而是rclcpp::Node本身。构造函数用初始化列表调用Node的构造函数,传入节点名。成员变量里publisher_和timer_的类型分别是rclcpp::Publisher<std_msgs::msg::String>::SharedPtr和rclcpp::TimerBase::SharedPtr,也就是说ROS2的句柄在C++里全是智能指针管理。

3.2 构造函数初始化列表与成员声明顺序

C++里有一个特别容易踩的坑:构造函数体内创建ROS2资源时,成员变量的构造顺序会出问题。比如你在初始化列表里写了Node("..."),这是没问题的;但如果你打算在声明成员变量时就初始化,比如:

rclcpp::Publisher<std_msgs::msg::String>::SharedPtr publisher_{this->create_publisher<...>(...)};

这种方式在成员构造时this指针的基类部分已经完全构造了吗?不一定。更稳妥的做法是在构造函数体里给成员赋值,等this指针完全合法以后再去调用create_publisher和create_wall_timer。上面示例里就是这么做的。

另外,C++成员变量的初始化顺序与它们在类中声明的顺序一致,和初始化列表里写的顺序无关。如果你有两个成员互相依赖,比如发布者要在定时器之前创建,那声明的顺序就要写对。否则编译器不会报错,但运行结果会非常诡异。

3.3 std::bind与this指针

C++里创建定时器回调,代码是std::bind(&MinimalTalker::timer_callback, this)。这个this一定不能丢。如果你忘了传this,那这个回调相当于在裸调用一个需要对象实例的成员函数,轻则编译报错,重则运行段错误。

不过,在C++里我更推荐直接用lambda表达式替代std::bind,代码更直观、更不容易出错:

timer_ = this->create_wall_timer( std::chrono::seconds(1), [this]() { timer_callback(); });

lambda捕获this之后,可以调用类的任何成员函数。ROS2的回调接口接收的可调用对象类型是统一的,lambda和std::bind都能用,只是lambda的可读性更好。尤其当回调要传参数的时候,lambda封装逻辑比一堆bind参数清晰得多。

3.4 智能指针生命周期:为什么用make_shared

主函数里写的是auto node = std::make_shared<MinimalTalker>();,然后rclcpp::spin(node)。这里一定不要直接写MinimalTalker node;再去spin。首先,ROS2的spin()接口接收的是Node的共享指针,不是引用。其次,节点执行期间可能被内部系统持有引用,智能指针能确保引用计数不为零。

类里成员变量用SharedPtr是同一个道理:发布者、订阅者、定时器可能被底层中间件线程引用,只能等它们都执行完毕、引用计数降到零才释放。所以只要看到rclcpp::Publisher<std_msgs::msg::String>::SharedPtr这种类型,潜意识里就要意识到:它的生命周期由引用计数管理,不要手动delete,不要用裸指针去接收返回值,更不要拿PublisherPtr去new一个新对象。

4. 继承与组合:让类节点不再只是“学会语法”

4.1 先看点有用的:一个公共基类

学完类节点,很多人会问:那继承到底怎么用啊?总不能在类里多写几个方法就叫继承吧。我给出一个非常实用的场景:你的机器人上有好几类传感器节点,每个节点都要做相同的初始化工作,比如统一命名空间、打印启动日志、声明一组公共参数。这时候把重复逻辑抽到基类里。

import rclpy from rclpy.node import Node from rcl_interfaces.msg import ParameterDescriptor class BaseSensorNode(Node): def __init__(self, node_name: str, namespace: str): super().__init__(node_name, namespace=namespace) self.declare_parameter("rate_hz", 10.0) self.declare_parameter("enable_debug", False) self.get_logger().info(f"base sensor node initialized: {node_name}")

然后两个业务节点继承它:

class LidarNode(BaseSensorNode): def __init__(self): namespace = "/sensors" super().__init__("lidar_node", namespace) rate = self.get_parameter("rate_hz").value self._timer = self.create_timer(1.0 / rate, self.publish_scan) def publish_scan(self): # 实际处理在这里 pass class CameraNode(BaseSensorNode): def __init__(self): namespace = "/sensors" super().__init__("camera_node", namespace) self._timer = self.create_timer(0.1, self.publish_image) def publish_image(self): # 图像处理逻辑 pass

这两个子类不需要重复写命名空间设置、参数声明和日志初始化逻辑。后面你想给所有传感器节点加一个统一的重启机制,只需要改基类。这种继承是合理的,因为它表达的是“is-a”关系:雷达节点是一个传感器节点,相机节点也是一个传感器节点。

4.2 场景化示例:雷达和相机的数据融合

继承真正发挥作用的地方在于,你可以用多态来处理一组相似对象。假设你写了一个融合节点,要同时订阅雷达和相机的数据:

class SensorFusionNode(BaseSensorNode): def __init__(self): super().__init__("fusion_node", "/sensors") self._lidar_sub = self.create_subscription( LaserScan, "scan", self.lidar_callback, 10) self._camera_sub = self.create_subscription( Image, "image", self.camera_callback, 10) self._lidar_data = None self._camera_data = None def lidar_callback(self, msg): self._lidar_data = msg self.try_fusion() def camera_callback(self, msg): self._camera_data = msg self.try_fusion() def try_fusion(self): if self._lidar_data is not None and self._camera_data is not None: self.get_logger().info("both sensor messages received")

你会发现,融合节点既不会继承LidarNode,也不会继承CameraNode,它通过成员变量持有两个订阅者。这种“组合”的写法,在处理跨传感器数据时比硬凑继承要优雅得多。所以继承和组合不是对立的,而是各有分工。

4.3 继承的边界:为什么我劝你别滥用多重继承

Python支持多重继承,C++也支持,但ROS2节点这块我强烈不建议你去用多重继承。假设你写一个class FusionNode(LidarNode, CameraNode),看起来好像是“既是雷达又是相机”,但两个父类各自都有_timer成员,命名会冲突,初始化顺序也会变得非常难控制。真遇到需要两种传感器数据,用组合,也就是各持有一个数据接口,比多重继承简单十倍。

还有一个隐性坑:Python的super()在多重继承中会触发MRO,也就是方法解析顺序,父类的__init__并不一定按你直觉里的顺序执行。你调试半天“怎么初始化顺序不对”,其实就是MRO在作怪。C++的多重继承则要面对菱形继承、虚基类、构造顺序等一堆问题。ROS2本身的代码没有为多重继承做设计,硬上只会给自己找麻烦。

4.4 实用原则:继承不超过两层,组合优先

我给身边的同事和读者划了一条比较实用的红线:ROS2节点类的继承层级,最多两层。第一层是公共基类,负责公共初始化和通用接口;第二层是具体业务节点。超过两层,代码的可读性和可追踪性会快速崩塌,改一个上层接口的影响面会特别大。

如果发现某个功能连子类都想复用时,先把功能抽成独立的普通类,比如一个DataProcessor、一个RosLogger,节点通过持有这些类的实例来使用它们。这样既不破坏节点本身清晰的责任边界,又能把代码复用落到实处。继承表达“是什么”,组合表达“有什么”,ROS2节点开发里,“有什么”的需求远大于“是什么”。

5. Python与C++类节点的关键差异对照

两种语言各有各的舒服和不舒服,列个对照表给你参考。

对比项PythonC++
类继承方式class MyNode(Node)class MyNode : public rclcpp::Node
构造函数def __init__(self): super().__init__("name")MyNode() : Node("name") {}
创建发布者self.create_publisher(MsgType, "topic", 10)this->create_publisher<MsgType>("topic", 10)
创建定时器self.create_timer(1.0, cb)this->create_wall_timer(std::chrono::seconds(1), cb)
日志输出self.get_logger().info("...")RCLCPP_INFO(this->get_logger(), "...")
参数声明self.declare_parameter("name", value)this->declare_parameter("name", value)
消息创建msg = String(); msg.data = "..."auto msg = std_msgs::msg::String(); msg.data = "..."
节点启动rclpy.spin(node)rclcpp::spin(node)

语言选择上,我的建议很直白:如果团队里Python底子好,或者核心工作是算法验证、原型演示、快速迭代,直接用Python,开发效率高到离谱。如果干的是实时控制系统、嵌入式设备,或者对延迟和资源占用有硬指标,那就老老实实用C++。这两者在系统里完全可以并存,ROS2本身就是跨语言的,一个机器人既可以有Python写的状态机节点,也可以有C++写的电机控制节点,互相之间完全不用关心对方是什么语言。

6. 常见错误与排查实录

6.1 Python容易踩的坑

第一个坑是忘了rclpy.init()直接创建类节点。你可能会在构造函数里就报“context not valid”之类的错误。记住,任何节点对象的创建都必须在rclpy.init()之后。

第二个坑是节点名重复。同一个进程中创建两个同名节点,后创建的那个会立即报错。节点名在进程内必须是唯一的,所以类构造函数里固定写死一个名字时,要想办法支持传入参数。上面我在MinimalTalker的构造参数里留了node_name,就是为了避免这个问题。

第三个坑是回调里做阻塞操作。Python的单线程执行器默认是在一个线程里顺序处理所有回调,你如果在一个回调里time.sleep(3),整个节点都跟着卡住,别的订阅、定时器全都不响应。真需要耗时的操作,要么放到独立线程里,要么换MultiThreadedExecutor。

6.2 C++编译与链接的坑

C++类节点最常见的报错就是头文件找不到。#include "rclcpp/rclcpp.hpp"是必须的,对应的消息头文件要引用对应包,比如#include "std_msgs/msg/string.hpp"。编译时CMakeLists.txt里要加上:

find_package(rclcpp REQUIRED) find_package(std_msgs REQUIRED) add_executable(minimal_talker src/minimal_talker.cpp) ament_target_dependencies(minimal_talker rclcpp std_msgs) install(TARGETS minimal_talker DESTINATION lib/${PROJECT_NAME})

忘写ament_target_dependencies是新手最常见的链接错误来源。还有一个坑是std::bind回调忘传this,编译不报错,但运行直接段错误。如果你用的是lambda,就没这个烦恼。

6.3 回调阻塞与线程安全

C++里的回调执行也有类似Python的阻塞问题。默认情况下,rclcpp::spin(node)是单线程执行器,一个回调卡住,全部回调排队。定时器回调里千万别写无谓的循环等待。

多线程执行场景下,无论是Python还是C++,都要注意回调之间可能并发访问同一个成员变量。比如发布线程、订阅回调、定时器回调共享一个_counter,就得加锁或用原子变量。ROS2的消息对象本身不是线程安全的,这个意识一定得有。

6.4 一些调试心得

类节点写完之后,我通常会在启动前做几件事。第一,ros2 node list看一下节点有没有正常注册;第二,ros2 node info <node_name>查看话题和服务的连接情况;第三,ros2 topic echo <topic_name>验证消息内容。类节点和脚本式节点在系统层面没有任何区别,这些调试工具一样好用。

如果是Python端,还有一个实用技巧:临时把节点的日志级别调到DEBUG,可以看到rclpy内部的调度细节。C++端则可以设置RCUTILS_LOG_LEVEL环境变量控制日志输出级别。遇到回调不触发、参数不对、消息不发布等问题时,先看日志,再断点,不要瞎猜。

写类节点这件事,说穿了就是把你的机器人逻辑封装成一个入口清晰、状态明确、便于测试的对象。我在实际项目里最大的感受是:用类节点之后,代码拆分工地变得顺理成章,一个人维护一个节点,节点之间通过消息接口默契配合,比在同一个文件里互相纠缠痛快太多了。个人的小习惯是每个节点类的构造函数末尾都会打一条启动日志,包含节点名和控制频率,方便后面排查问题的时候一眼看出系统当时的状态。下篇我们接着聊生命周期节点和组件化,类节点这块继续往深走。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询