C++避坑指南(四)

关注公众号【高性能架构探索】,第一时间获取最新文章;公众号内回复【pdf】,免费获取经典书籍

枚举

枚举类型原本是用于解决固定范围取值的类型表示,但由于在 C 语言中被定义为了整型类型的一种语法糖,导致枚举类型的使用上出现了一些问题。

1. 无法前置声明

枚举类型无法先声明后定义,例如下面这段代码会编译报错:

enum Season;

struct Data {
  Season se; // ERR
};

enum Season {
  Spring,
  Summer,
  Autumn,
  Winter
};

主要是因为enum类型是动态选择基础类型的,比如这里只有 4 个取值,那么可能会选取int16_t,而如果定义的取值范围比较大,或者中间出现大枚举值的成员,那么可能会选取int32_t或者int64_t。也就是说,枚举类型如果没定义完,编译期是不知道它的长度的,因此就没法前置声明。

C++中允许指定枚举的基础类型,制定后可以前置声明:

enum Season : int;

struct Data {
  Season se; // OK
};

enum Season : int {
  Spring,
  Summer,
  Autumn,
  Winter
};

但如果你是在调别人写的库的时候,人家的枚举没有指定基础类型的话,那你也没辙了,就是不能前置声明。

2. 无法确认枚举值的范围

也就是说,我没有办法判断某个值是不是合法的枚举值:

enum Season {
  Spring,
  Summer,
  Autumn,
  Winter
};

void Demo() {
  Season s = static_cast<Season>(5); // 不会报错
}

3. 枚举值可以相同

enum Test {
  Ele1 = 10,
  Ele2,
  Ele3 = 10
};

void Demo() {
  bool judge = (Ele1 == Ele3); // true
}

4. C 风格的枚举还存在“成员名称全局有效”和“可以隐式转换为整型”的缺陷

但因为 C++提供了enum class风格的枚举类型,解决了这两个问题,因此这里不再额外讨论。

宏这个东西,完全就是针对编译器友好的,编译器非常方便地在宏的指导下,替换源代码中的内容。但这个玩意对程序员(尤其是阅读代码的人)来说是极其不友好的,由于是预处理指令,因此任何的静态检测均无法生效。一个经典的例子就是:

#define MUL(x, y) x * y

void Demo() {
  int a = MUL(1 + 2, 3 + 4); // 11
}

因为宏就是简单粗暴地替换而已,并没有任何逻辑判断在里面。

宏因为它很“好用”,所以非常容易被滥用,下面列举了一些宏滥用的情况供参考:

1. 用宏来定义类成员

#define DEFAULT_MEM     \
public:                 \
int GetX() {return x_;} \
private:                \
int x_;

class Test {
DEFAULT_MEM;
 public:
  void method();
};

这种用法相当于屏蔽了内部实现,对阅读者非常不友好,与此同时加不加 DEFAULT_MEM 是一种软约束,实际开发时极容易出错。

再比如这种的:

#define SINGLE_INST(class_name)                        \
 public:                                               \
  static class_name &GetInstance() {                   \
    static class_name instance;                        \
    return instance;                                   \
  }                                                    \
  class_name(const class_name&) = delete;              \
  class_name &operator =(const class_name &) = delete; \
 private:                                              \
  class_name();

class Test {
  SINGLE_INST(Test)
};

这位同学,我理解你是想封装一下单例的实现,但咱是不是可以考虑一下更好的方式?(比如用模板)

2. 用宏来屏蔽参数

#define strcpy_s(dst, dst_buf_size, src) strcpy(dst, src)

这位同学,咱要是真想写一个安全版本的函数,咱就好好去判断 dst_buf_size 如何?

3. 用宏来拼接函数处理

define COPY_IF_EXSITS(dst, src, field) \
do {                                    \
  if (src.has_##field()) {              \
    dst.set_##field(dst.field());       \
  }                                     \
} while (false)

void Demo() {
  Pb1 pb1;
  Pb2 pb2;

  COPY_IF_EXSITS(pb2, pb1, f1);
  COPY_IF_EXSITS(pb2, pb1, f2);
}

这种用宏来做函数名的拼接看似方便,但最容易出的问题就是类型不一致,加入pb1pb2中虽然都有f1这个字段,但类型不一样,那么这样用就可能造成类型转换。试想pb1.f1uint64_t类型,而pb2.f1uint32_t类型,这样做是不是有可能造成数据的截断呢?

4. 用宏来改变语法风格

#define IF(con) if (con) {
#define END_IF }
#define ELIF(con) } else if (con) {
#define ELSE } else {

void Demo() {
  int a;
  IF(a > 0)
    Process1();
  ELIF(a < -3)
    Process2();
  ELSE
    Process3();
}

这位同学你到底是写 python 写惯了不适应 C 语法呢,还是说你为了让代码扫描工具扫不出来你的圈复杂度才出此下策的呢~~

共合体

共合体的所有成员共用内存空间,也就是说它们的首地址相同。在很多人眼中,共合体仅仅在“多选一”的场景下才会使用,例如:

union QueryKey {
  int id;
  char name[16];
};

int Query(const QueryKey &key);

上例中用于查找某个数据的 key,可以通过 id 查找,也可以通过 name,但只能二选一。

这种场景确实可以使用共合体来节省空间,但缺点在于,共合体的本质就是同一个数据的不同解类型,换句话说,程序是不知道当前的数据是什么类型的,共合体的成员访问完全可以用更换解指针类型的方式来处理,例如:

union Un {
  int m1;
  unsigned char m2;
};

void Demo() {
  Un un;
  un.m1 = 888;
  std::cout << un.m2 << std::endl;
  // 等价于
  int n1 = 888;
  std::cout << *reinterpret_cast<unsigned char *>(&n1) << std::endl;
}

共合体只不过把有可能需要的解类型提前写出来罢了。所以说,共合体并不是用来“多选一”的,笔者认为这是大家曲解的用法。毕竟真正要做到“多选一”,你就得知道当前选的是哪一个,例如:

struct QueryKey {
  union {
    int id;
    char name[16];
  } key;
  enum {
    kCaseId,
    kCaseName
  } key_case;
};

用过 google protobuf 的读者一定很熟悉上面的写法,这个就是 proto 中oneof语法的实现方式。

在 C++17 中提供了std::variant,正是为了解决“多选一”问题存在的,它其实并不是为了代替共合体,因为共合体原本就不是为了这种需求的,把共合体用做“多选一”实在是有点“屈才”了。

更加贴合共合体本意的用法,是我最早是在阅读处理网络报文的代码中看到的,例如某种协议的报文有如下规定(例子是我随便写的):
C++避坑指南(四)
这里能看出来,整个报文有 2 字节,一般的处理时,我们可能只需要关注这个报文的这 2 个字节值是多少(比如说用十六进制表示),而在排错的时候,才会关注报文中每一位的含义,因此,“整体数据”和“内部数据”就成为了这段报文的两种获取方式,这种场景下非常适合用共合体:

union Pack {
  uint16_t data; // 直接操作报文数据
  struct {
    unsigned version : 4;
    unsigned timeout : 2;
    unsigned retry_times : 1;
    unsigned block : 1;
    uint8_t bus_data;
  } part; // 操作报文内部数据
};

void Demo() {
  // 例如有一个从网络获取到的报文
  Pack pack;
  GetPackFromNetwork(pack);
  // 打印一下报文的值
  std::printf("%X", pack.data);
  // 更改一下业务数据
  pack.part.bus_data = 0xFF;
  // 把报文内容扔到处理流中
  DataFlow() << pack.data;
}

因此,这里的需求就是“用两种方式来访问同一份数据”,才是完全符合共合体定义的用法。

共合体应该是 C 语言的特色了,其他任何高级语言都没有类似的语法,主要还是因为 C 语言更加面相底层,C++仅仅是继承了 C 的语法而已。

原创文章受到原创版权保护。转载请注明出处:https://www.ccppcoding.com/archives/1254

非原创文章文中已经注明原地址,如有侵权,联系删除

关注公众号【高性能架构探索】,第一时间获取最新文章

转载文章受原作者版权保护。转载请注明原作者出处!

(0)
上一篇 2022年11月2日 下午12:15
下一篇 2022年11月2日 下午12:17

相关推荐