21 | 知识串讲(上):带你开发一个书店应用

到今天为止,课程里的 C++ 知识就全部讲完了。前面我们总共学习了四大模块,再做一个简略的回顾。

在“概论”单元,我带你从宏观的层面上重新认识了 C++,讲了它的四个生命周期和五个编程范式,分别介绍了在编码阶段、预处理阶段、编译阶段,C++ 能够做哪些事情,接着又重点说了在 C++ 里,运用哪些特性才能更好地实践面向对象编程。

在“语言特性”单元,我们一起研究了自动类型推导、常量、智能指针、异常、函数式编程这五个特性。这些特性是“现代”C++ 区别于“传统”C++ 的关键,掌握了它们,你就能够写出清晰、易读、安全的代码。

在“标准库”单元,我介绍了字符串、容器、算法和并发。它们是 C++ 标准库中最核心的部分,也是现代 C++ 和泛型编程的最佳应用和范例。学会了标准库,你才能说是真正理解了 C++。

在“技能进阶”单元,我为你挑选出了一些第三方工具,包括序列化、网络通信、脚本语言和性能分析,它们很好地补充完善了 C++ 语言和标准库,免去了我们“自己造轮子”的麻烦,让我们把精力集中在实现业务逻辑上。

除了上面的这“十八般武艺”,我还谈了谈能够帮你更好地运用 C++ 的设计模式和设计原则,介绍了几个比较重要、常用的模式,希望你在今后的实际开发工作中,能够有意识地写出灵活、可扩展的代码。

这么回顾下来,内容还真是不少啊。

为了让你更好地把这些知识融会贯通,接下来会再用两节,从需求、设计,到开发编码、编译运行,再加上一些我自己的实用小技巧,详细讲解一个 C++ 程序的实际开发过程,把知识点都串联起来。

虽然说是“串讲”,但是你只要学过了前面的内容,就可以跟着我做出这个书店程序。不过,我担心有些知识点你可能忘记了,所以,涉及到具体的知识点时,会标注出是在哪一节,可以随时回去复习一下。

项目设计

那么,该用个什么样的例子来串讲 C++ 的这些知识点呢?

说实话,找出一个合适的例子真的很难。因为大多数 C++ 实际项目都很大、很底层,还有各种依赖或者内部库,不好直接学习研究。

所以我再三考虑,决定借鉴一下 C++ Primer 里的书店例子,修改一下它的需求,然后完全重新开发,作为我们这个课程的综合示例。

先介绍一下咱们这个书店程序。简单来说,就是销售记录管理,从多个渠道把书号、销售册数、销售额都汇总起来,做个统计分析,再把数据定期上报到后台。

C++ Primer 里的书店程序是本地运行的,为了演示课程里讲到的的 C++ 特性,把它改成了网络版。不过,拓扑结构并不复杂,画了张图,你可以看一下。

项目的前期需求就算是定下来了,接着就要开始做设计了,这就要用到设计模式和设计原则的知识了(第 19 讲、第 20 讲)。

不过这个系统还是比较简单的,不需要用什么复杂的分析手段,就能够得出设计,主要应用的是单一职责原则、接口隔离原则和包装外观模式。这里也画了一个 UML 图,可以帮助你理解程序的架构。 

下面就对照这个 UML 类图,结合开发思路和源码,仔细说一下具体的 C++ 开发,完整的源码都放在了GitHub上,课下可以仔细地看一下。

核心头文件

首先要说的是写 C++ 项目的一个习惯,定义核心头文件:cpplang.hpp。它集中了 C++ 标准头和语言相关的定义,被用于其他所有的源文件。

注意,在写它的时候,最好要有文件头注释(第 2 讲),而且要有“Include guard”(第 3 讲),就像下面这样: 

// Copyright (c) 2020 by Chrono

#ifndef _CPP_LANG_HPP        // Include guard
#define _CPP_LANG_HPP        // Include guard

#include <cassert>           // C++标准头文件
...

#endif  //_CPP_LANG_HPP

在核心头文件里,我们还可以利用预处理编程,使用宏定义、条件编译来屏蔽操作系统、语言版本的差异,增强程序的兼容性。

比如,这里就检查了 C++ 的版本号,然后定义了简化版的“deprecated”和“static_assert”:

// must be C++11 or later
#if __cplusplus < 201103
#   error "C++ is too old"
#endif  // __cplusplus < 201103

// [[deprecated]]
#if __cplusplus >= 201402
#   define  CPP_DEPRECATED [[deprecated]]
#else
#   define  CPP_DEPRECATED [[gnu::deprecated]]
#endif  // __cplusplus >= 201402

// static_assert
#if __cpp_static_assert >= 201411
#   define STATIC_ASSERT(x) static_assert(x)
#else
#   define STATIC_ASSERT(x) static_assert(x, #x)
#endif

自旋锁

有了核心头文件之后,我们的 C++ 程序就有了一个很好的起点,就可以考虑引入多线程,提高吞吐量,减少阻塞。

在多线程里保护数据一般要用到互斥量(Mutex),但它的代价太高,所以设计了一个自旋锁,它使用了原子变量,所以成本低,效率高(第 14 讲)。

自旋锁被封装为一个 SpinLock 类,所以就要遵循一些 C++ 里常用的面向对象的设计准则(第 5 讲、第 19 讲),比如用 final 禁止继承、用 default/delete 显式标记构造 / 析构函数、成员变量初始化、类型别名,等等,可以看看代码:

class SpinLock final                        // 自旋锁类
{
public:
    using this_type   = SpinLock;          // 类型别名
    using atomic_type = std::atomic_flag;
public:
    SpinLock() = default;                 // 默认构造函数
   ~SpinLock() = default;

    SpinLock(const this_type&) = delete;  // 禁止拷贝
    SpinLock& operator=(const this_type&) = delete;
private:
    atomic_type m_lock {false};            // 成员变量初始化
};

在编写成员函数的时候,为了尽量高效,需要给函数都加上 noexcept 修饰,表示绝不会抛出异常(第 9 讲):

public:
  void lock() noexcept              // 自旋锁定,绝不抛出异常
  {
    for(;;) {                      // 无限循环
      if (!m_lock.test_and_set()) { // 原子变量的TAS操作
          return;                  // TAS成功则锁定
      }
      std::this_thread::yield();   // TAS失败则让出线程
    }
  }

  void unlock() noexcept           // 解除自旋锁定,绝不抛出异常
  {
    m_lock.clear();
  }

为了保证异常安全,在任何时候都不会死锁,还需要利用 RAII 技术再编写一个 LockGuard 类。它在构造时锁定,在析构时解锁,这两个函数也应该用 noexcept 来优化:

class SpinLockGuard final                      // 自旋锁RAII类,自动解锁
{
public:
    using this_type      = SpinLockGuard;      // 类型别名
    using spin_lock_type = SpinLock;
public:
    SpinLockGuard(const this_type&) = delete;  // 禁止拷贝
    SpinLockGuard& operator=(const this_type&) = delete;
public:
    SpinLockGuard(spin_lock_type& lock) noexcept
        : m_lock(lock)
    {
        m_lock.lock();
    }

   ~SpinLockGuard() noexcept
   {
       m_lock.unlock();
   }
private:
    spin_lock_type& m_lock;
};

这样自旋锁就完成了,有了它就可以在多线程应用里保护共享的数据,避免数据竞争。

网络通信

自旋锁比较简单,但多线程只是书店程序的基本特性,它的核心关键词是“网络”,所以下面就来看看服务里的“重头”部分:网络通信。

正如之前说的,在现代 C++ 里,应当避免直接使用原生 Socket 来编写网络通信程序(第 16 讲)。这里我选择 ZMQ 作为底层通信库,它不仅方便易用,而且能够保证消息不丢失、完整可靠地送达目的地。

程序里使用 ZmqContext 类来封装底层接口(包装外观),它是一个模板类,整数模板参数用来指定线程数,在编译阶段就固定了 ZMQ 的多线程处理能力。

对于 ZMQ 必需的运行环境变量(单件),使用了一个小技巧:以静态成员函数来代替静态成员变量。这样就绕过了 C++ 的语言限制,不必在实现文件“*.cpp”里再写一遍变量定义,全部的代码都可以集中在 hpp 头文件里:

template<int thread_num = 1>        // 使用整数模板参数来指定线程数
class ZmqContext final
{
public:
    static                          // 静态成员函数代替静态成员变量
    zmq_context_type& context()
    {
        static zmq_context_type ctx(thread_num);
        return ctx;
    }
};

然后,我们要实现两个静态工厂函数,创建收发数据的 Socket 对象。

这里要注意,如果你看 zmq.hpp 的源码,就会发现,它的内部实际上是使用了异常来处理错误的。所以,这里我们不能在函数后面加上 noexcept 修饰,同时也就意味着,在使用 ZMQ 的时候,必须要考虑异常处理。

public: 
  static
  zmq_socket_type recv_sock(int hwm = 1000)    // 创建接收Socket
  {
    zmq_socket_type sock(context(), ZMQ_PULL); // 可能抛出异常
    
    sock.setsockopt(ZMQ_RCVHWM, hwm);

    return sock;
  }

  static
  zmq_socket_type send_sock(int hwm = 1000)   // 创建发送Socket
  {
    zmq_socket_type sock(context(), ZMQ_PUSH); // 可能抛出异常

    sock.setsockopt(ZMQ_SNDHWM, hwm);

    return sock;
  }

现在,有了 ZmqContext 类,书店程序的网络基础也就搭建出来了,后面就可以用它来收发数据了。

配置文件解析

接下来,我要说的是解析配置文件的类 Config。

大多数程序都会用到配置文件来保存运行时的各种参数,常见的格式有 INI、XML、JSON,等等。但我通常会选择把 Lua 嵌入 C++,用 Lua 脚本写配置文件(第 17 讲)。

这么做的好处在哪里呢?

Lua 是一个完备的编程语言,所以写起来就非常自由灵活,比如添加任意的注释,数字可以写成“m × n”的运算形式。而 INI、XML 这些配置格式只是纯粹的数据,很难做到这样,很多时候需要在程序里做一些转换工作。

另外,在 Lua 脚本里,我们还能基于 Lua 环境写一些函数,校验数据的有效性,或者采集系统信息,实现动态配置。

总而言之,就是把 Lua 当作一个“可编程的配置语言”,让配置“活起来”。

给你看一下配置文件的代码吧,里面包含了几个简单的值,配置了服务器的地址、时间间隔、缓冲区大小等信息:

config = {

    -- should be same as client
    -- you could change it to ipc
    zmq_ipc_addr = "tcp://127.0.0.1:5555",

    -- see http_study's lua code
    http_addr = "http://localhost/cpp_study?token=cpp@2020",

    time_interval = 5,  -- seconds

    max_buf_size = 4 * 1024,
}

Config 类使用 shared_ptr 来管理 Lua 虚拟机(第 17 讲),因为封装在类里,所以,你要注意类型别名和成员变量初始化的用法(第 5 讲):

class Config final                  // 封装读取Lua配置文件
{
public:
    using vm_type      = std::shared_ptr<lua_State>;   // 类型别名
    using value_type   = luabridge::LuaRef;
public:
    Config() noexcept               // 构造函数
    {
        assert(m_vm);
        luaL_openlibs(m_vm.get());  // 打开Lua基本库
    }
   ~Config() = default;             // 默认析构函数
private:
    vm_type     m_vm                 // 类型别名定义Lua虚拟机
      {luaL_newstate(), lua_close};  // 成员变量初始化
};

加载 Lua 脚本的时候还要注意一点,外部的脚本有可能会写错,导致 Lua 解析失败。但因为这个问题极少出现,而且一出现就很严重,没有配置就无法走后续的流程,所以非常适合用异常来处理(第 9 讲)。

load() 函数不会改变虚拟机成员变量,所以应该用 const 修饰,是一个常函数:

public:
  void load(string_view_type filename) const  // 解析配置文件
  {
    auto status = luaL_dofile(m_vm.get(), filename.c_str());

    if (status != 0) {                       // 出错就抛出异常
        throw std::runtime_error("failed to parse config");
    }
  }

为了访问 Lua 配置文件里的值,我决定采用“key1.key2”这样简单的两级形式,有点像 INI 的小节,这也正好对应 Lua 里的表结构。

想要解析出字符串里的前后两个 key,可以使用正则表达式(第 11 讲),然后再去查询 Lua 表。

因为构造正则表达式的成本很高,所以我把正则对象都定义为成员变量,而不是函数里的局部变量。

正则的匹配结果(m_what)是“临时”的,不会影响常量性,所以要给它加上 mutable 修饰。

private:
  const   regex_type  m_reg {R"(^(\w+)\.(\w+)$)"};
  mutable match_type  m_what;              // 注意是mutable

在 C++ 正则库的帮助下,处理字符串就太轻松了,拿到两个 key,再调用 LuaBridge 就可以获得 Lua 脚本里的配置项。

不过,为了进一步简化客户代码,我把 get() 函数改成了模板函数,显式转换成 int、string 等 C++ 标准类型,可读性、可维护性会更好。

public:
  template<typename T>                    // 转换配置值的类型
  T get(string_view_type key) const      // const常函数
  {
    if (!std::regex_match(key, m_what, m_reg)) {  // 正则匹配
        throw std::runtime_error("config key error");// 格式错误抛异常
    }

    auto w1 = m_what[1].str();            // 取出两个key
    auto w2 = m_what[2].str();

    auto v = getGlobal(                  // 获取Lua表
                m_vm.get(), w1.c_str());

    return LuaRef_cast<T>(v[w2]);        // 取表里的值,再做类型转换
  }

到这里呢,Config 类也就完成了,可以轻松解析 Lua 格式的配置文件。

小结

今天,用一个书店程序作为例子,把前面的知识点都串联起来,应用到了这个“半真实”的项目里,完成了 UML 类图里的外围部分。你也可以把刚才说的核心头文件、自旋锁、Lua 配置文件这些用法放到自己的实际项目里去试试。

简单小结一下今天的内容:

1.  在项目起始阶段,应该认真做需求分析,然后应用设计模式和设计原则,得出灵活、可扩展的面向对象系统;

2. C++ 项目里最好要有一个核心头文件(cpplang.hpp),集中定义所有标准头和语言特性,规范源文件里的 C++ 使用方式;

3. 使用原子变量(atomic)可以实现自旋锁,比互斥量的成本要低,更高效;

4. 使用 ZMQ 可以简化网络通信,但要注意它使用了异常来处理错误;

5. 使用 Lua 脚本作为配置文件的好处很多,是“可编程的配置文件”;

6. 在编写代码时要理解、用好 C++ 特性,恰当地使用 final、default、const 等关键字,让代码更安全、更可读,有利于将来的维护。

今天,我们分析了需求,设计出了架构,开发了一些工具类,但还没有涉及业务逻辑代码,下节会带你看看容器、算法、线程,还有 lambda 表达式的实践应用,看看它们是怎么服务于具体业务的。

课下作业

你能说出,程序里是怎么应用设计模式和设计原则的吗?



本文以C++知识串讲为主题,通过回顾前面所学的C++知识,介绍了概论、语言特性、标准库和技能进阶等四大模块的内容。作者以开发一个书店应用为例,详细讲解了C++程序的实际开发过程,包括项目设计、核心头文件的定义以及自旋锁的实现。文章突出了C++的特性和技术要点,涵盖了C++的现代特性、多线程编程、设计模式和设计原则等内容,为读者提供了一个全面的C++知识学习和实践的指南。文章还介绍了网络通信和配置文件解析的实现细节,展示了作者对于C++技术的深入理解和实际运用。通过对需求分析、设计模式和设计原则的应用,以及对C++特性的恰当使用,读者可以获得对C++编程的深入理解和实践经验。文章内容丰富,涵盖了C++编程的多个方面,对于想要系统学习和实践C++编程的读者来说,是一篇非常有价值的文章。 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值