BlogFM
BlogFM copied to clipboard
自己常用的C/C++小技巧[4]
自己常用的小技巧
这里列出了自己常用的一些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_back
和std::vector<float>::push_back
由于int32_t
和float
大小一致, 这两条函数可能会被优化合并为一条函数. 大致同理, 我们可以把这种没有构造/析构的对象单独拿出来实现一个专门储存这一类的容器, 经过内联后, 这一类调用的函数会完全指向同一个实现.
为了方便起见, 就称为"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_back
和std::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
这个结论也很简单, 那么有什么好讨论的呢? 那就是new
和delete
被称为"操作符", 还允许被重载:
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
.