BlogFM icon indicating copy to clipboard operation
BlogFM copied to clipboard

自己常用的C/C++小技巧[4]

Open dustpg opened this issue 6 years ago • 0 comments

自己常用的小技巧

这里列出了自己常用的一些c/c++小技巧, 有些会有不足, 可以简单探讨一下.

大小端

分类: 简单讨论

"大小端"这一名称来自很有意思. 不过, 在这里, 大致就是怎么处理"字节序"的. 我们用char, char16_t以及char32_t描述'A'的话就是这样的:

(按地址增长方向排列)

              小端序        大端序        混合序
char           'A'           'A'          'A'
char16_t      'A',0        0,'A'     (?) 'A',0
char32_t     'A',0,0,0    0,0,0,'A'  (?)0,0,'A',0

一般来说只需要考虑大小端, 这两个即可, 混合序的程序估计得定制.

如何判断大小端呢? 终于在c++20中有了std::endian. 同时对于类gcc编译器可以使用:

// is little endian
struct is_little_endian { enum : bool { value = __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ }; };
// is big endian
struct is_big_endian { enum : bool { value = __BYTE_ORDER__ == __ORDER_BIG_ENDIAN__ }; };
// is pdp endian
struct is_pdp_endian { enum : bool { value = __BYTE_ORDER__ == __ORDER_PDP_ENDIAN__ }; };

限制模板膨胀

分类: 体积优化

c++模板极大地方便了代码编写, 但是有一个重要的问题就是如果滥用, 模板膨胀会极大地增加程序体积. 可能不少人会不在意, 毕竟内存白菜价. 不过也会有像我这种比起时间复杂度, 也会在意空间的.

上一部分我们用的是char自然是引出这部分: 字符串处理中部分限制. 比如c++标准模板库中<string>中有:

类型                     定义
std::string              std::basic_string<char>
std::wstring             std::basic_string<wchar_t>
std::u16string (C++11)   std::basic_string<char16_t>
std::u32string (C++11)   std::basic_string<char32_t>

虽然有非常多的文字编码, 但是或多或小地会在低字节与ASCII兼容. 我们在处理纯英文数字时(比如字符串转成数字)会仅仅处理ASCII部分, 利用字符之间的间隔就能轻松地在一条函数实现char等三种.

例如现在实现了一条函数, 字符串转换整型:

int32_t my_atoi(const char* ptr, size_t len) {
    // 具体实现
    ++ptr;
    // 具体实现
}

然后把ptr++之类的操作换成ptr+=n即可:

int32_t my_atoi_ex(const char* ptr, size_t len, size_t n) {
    // 具体实现
    ptr += n;
    // 具体实现
}

然后就是大小端了. 最低位在char16_t, char32_t中偏移不一样. 偏移量在上面列出了, 然后用模板特化就能编译期获取偏移量, 这个就不累述了. 最后就能实现为:

extern "C" int32_t my_atoi_ex(const char* ptr, size_t len, size_t n);

namespace detail {
    // ascii offset
    template<unsigned SIZE> struct ascii_offset;
    // ascii offset for 1 [char]
    template<> struct ascii_offset<1> { enum { value = /*impl*/ }; };
    // ascii offset for 2 [char16_t]
    template<> struct ascii_offset<2> { enum { value = /*impl*/ }; };
    // ascii offset for 4 [char32_t]
    template<> struct ascii_offset<4> { enum { value = /*impl*/ }; };
}

template<typename T> inline
int32_t MyAtoI(const T* str, size_t len) {
    const auto ptr = reinterpret_cast<const char*>(str) + detail::ascii_offset<sizeof(T)>::value;
    return ::my_atoi_ex(ptr, len, sizeof(T));
}

这是关于字符串处理相关限制模板, 理论上节约了2/3的代码空间. 接下来就是关于容器方面的限制.

c++用模板封装容器就能非常方便地使用, 这里就是以类std::vector的实现讨论这一问题. 向量, 或者还是用数组比较顺口储存了连续的数据.

在实际实现上 std::vector<int32_t>::push_backstd::vector<float>::push_back由于int32_tfloat大小一致, 这两条函数可能会被优化合并为一条函数. 大致同理, 我们可以把这种没有构造/析构的对象单独拿出来实现一个专门储存这一类的容器, 经过内联后, 这一类调用的函数会完全指向同一个实现.

为了方便起见, 就称为"POD Vector", 我们可以通过std::is_pod 再配合static_assert防止程序猿作死. 自己在pod_vector.h, pod_vector.cpp 里实现了. 大致结构为:


class basic_vector {
    char*       data;
    uint32_t    length;
    uint32_t    capacity;
    uint32_t    size;
};

template<typename T>
class pod_vector : protected basic_vector {
    static_assert(std::is_pod<T>::value, "type T must be POD");
};

pod_vector在外层实现几乎兼容std::vector以方便使用. 这样随便用pod_vector也无需担心模板膨胀, 唯一低效率的情况就是例如pod_vector<char>::push_back效率较低. 说到char, 各位也会发现"字符串"也可以用这种方法实现!

特别地, 通过简单改造basic_vector, 字符串就能直接也能用pod_vector实现, LongUI中, 三种字符串char char16_t char32_t均是POD Vector的实现, 以实现轻量级目标.

可能大家会说, 只能用POD没啥用啊. 但是我们也可以实现不会模板膨胀的"非POD Vector". 自己在之前C++ 获取构造函数/析构函数的函数指针提到了, 我们可以使用类似于虚表的东西, 实现不会模板膨胀的"非POD Vector", 缺点就是用于 用于本身重点侧重于RAII的(例如智能指针之类构造析构比较短的), 用起来"不划算". 大型对象还是很划算的. 自己虽然实现了, 但是没用过所以应该有BUG(没用过的主要原因是因为标准库没有类似try_realloc的函数, 有的话效率在修改容量时大大提升速度).

常量折叠

分类: 空间优化

前面提到, std::vector<int32_t>::push_backstd::vector<float>::push_back可能会被"合并". 其根本原因其实是链接器的链接时优化, 把相同的常量折叠为一个, 从而减少程序体积. 这个就被称为"常量折叠".

我们当然可以进行扩展, 手动在运行时实现常量折叠. 这里先举一个特殊的例子. 之前说到工厂模式中链表使用头节点与尾节点方便处理:

struct Node {
    Node*   prev;
    Node*   next;
};
class Factory {
    Node    head;
    Node    tail;
};

其中, head的prev和tail的next会一直为nullptr. 所以, 我们可以把他们折叠在一起!

void* data[3]:

            tail     head     

[0] xxxx    prev
[1] null    next     prev
[2] yyyy             next

当然, 用于工厂模式没什么用, 就节约一个指针而已. 不过如果是树的一个实现, 那么每个节点都能节约一个指针就..."还行"...

虽然我们用"折叠"这个词汇, 核心思想是"复用"数据. c++17中的string_view其思想其实也是这个.

例如一个字符串PI=3.14159, 经过词法分析后可能被拆为"PI", "=", "3.14159"三部分. 如果每个部分都重新申请就会申请3个很短的字符串, 当然是非常不"划算". string_view就只需要在原来的字符串标记处起点与终点就能"复用"原来的字符串.

所以不要局限于"C风格(NUL结尾)"的字符串, 将自己所有字符串处理函数添加字符串的长度参数, 然后按照该长度处理也是极好的.(之前例子中的my_atoi拥有长度参数, 可以直接处理string_view!)

并且, "常量折叠"运行期可以升级为"同"量折叠. 这里修改了原字符串就能影响所有的string_view.

虚析构函数 虚释放函数

分类: 简单讨论

可能会在有些地方会建议"将所有析构函数声明为虚析构函数", 然后可能用着用着, 发现一个final关键字比较好. 不过这里讨论的不是该不该用, 而是怎么用: 虚析构函数, 还是虚释放函数:

struct Foo {
    virtual ~Foo(){}
};
struct Bar {
    virtual void Dispose() { delete this; }
};

释放一个继承于基础类的对象, 比较简单的就是这两种. 那么用那种呢? 这里先说说自己的建议:

  • 对称原则
  • 如果用new的, 就用delete
  • 如果用类似Bar::Create的, 就用#Dispose

这个结论也很简单, 那么有什么好讨论的呢? 那就是newdelete被称为"操作符", 还允许被重载:

struct Foo {
    static void operator delete (void* p) { ::delete p; }
    virtual ~Foo() {  }
};

struct Bar : Foo {
    static void operator delete (void* p) { ::delete p; }
    virtual ~Bar() {  }
};

int main() {
    Foo* foo = new(std::nothrow) Bar;
    delete foo;
}

哪个operator delete会被调用呢?

我们会一般用重载(overload)操作符, 而不是说重写(override)操作符(cppreference的说法: Class-specific overloads), 加上static的迷惑性会让代码产生一些歧义. 当然, 这很大程度上仅仅是自己对c++了解不够, 所以产生的迷惑.

这里所讨论的就是, 如果可能基继承类会修改了内存分配的情况, 这种情况很少见, 但是如果出现了建议使用虚释放函数. 这里一点, Windows上的COM对象就是使用了Release.

dustpg avatar Oct 24 '18 09:10 dustpg