一、RAII统计函数耗时
RAII,也称为“资源获取就是初始化”,是c++等编程语言常用的管理资源、避免内存泄露的方法。它保证在任何情况下,使用对象时先构造对象,最后析构对象。
经典使用场景
避免死锁
class MyLock
{
public:
MyLock(){
_mutex.lock()
islocked = true;
}
~MyLock(){
if(islocked){
_mutex.unlock();
}
islocked = false;
}
bool unLock(){
if(islocked){
_mutex.unlock();
}
islocked = false;
}
private:
bool islocked = false;
std::mutex _mutex;
private:
MyLock( const MyLock &);
MyLock operator =(const MyLock &);
};
避免内存泄漏
class RAIIInstance
{
public:
RAIIInstance(int size){
mem = new char[size];
memset(mem,0,size);
msize = size;
}
~RAIIInstance(){
if(mem){
delete mem;
mem = nullptr
}
msize = 0;
}
char *getAddr(){
return mem;
}
bool setValue(char *value,int size){
if(size > msize ){
return false;
}
memcpy(mem,value,size);
};
private:
int msize = 0;
char * mem=nullptr;
};
一般统计耗时示例代码:
void Func() {
...
}
int CalTime() {
int begin = GetCurrentTime(); // 伪代码
Func();
int end = GetCurrentTime();
cout << "func time is " << end - begin << " s" << endl;
}
利用RAII方式,把函数的生命周期和一个对象绑定,对象创建时候执行函数,对象生命周期结束析构时候函数执行完毕,这样对象存活的时间就是函数的耗时:
#pragma once
#include <chrono>
#include <ctime>
#include <fstream>
#include <iostream>
#include <string>
using llong = long long;
using namespace std::chrono;
using std::cout;
using std::endl;
namespace wzq {
namespace timer {
class TimerLog {
public:
TimerLog(const std::string tag) { // 对象构造时候保存开始时间
m_begin_ = high_resolution_clock::now();
m_tag_ = tag;
}
void Reset() { m_begin_ = high_resolution_clock::now(); }
llong Elapsed() {
return static_cast<llong>(
duration_cast<std::chrono::milliseconds>(high_resolution_clock::now() - m_begin_).count());
}
~TimerLog() { // 对象析构时候计算当前时间与对象构造时候的时间差就是对象存活的时间
auto time =
duration_cast<std::chrono::milliseconds>(std::chrono::high_resolution_clock::now() - m_begin_).count();
std::cout << "time { " << m_tag_ << " } " << static_cast<double>(time) << " ms" << std::endl;
}
private:
std::chrono::time_point<std::chrono::high_resolution_clock> m_begin_;
std::string m_tag_;
};
} // namespace timer
} // namespace wzq
使用方式:
void TestTimerLog() {
auto func = [](){
for (int i = 0; i < 5; ++i) {
cout << "i " << i << endl;
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
};
{
wzq::timer::TimerLog t("func");
func();
}
}
二、PIMPL模式
PIMPL是指pointer to implementation,又称作“编译防火墙”。它通过将类B放置在单独的类A中,使用B的不透明指针进行访问实现,从而隐藏了A类的实现细节。是实现“将文件间的编译依存关系降至最低”的方法之一。
1、示例
一般场景示例代码:
// book.h
#pragma once
#include <iostream>
class book
{
public:
book(std::string name,double price);
~book();
void display();
void set_price(double price);
private:
std::string book_name;
double book_price;
};
// book.cpp
#include "book.h"
book::book(std::string name, double price) :book_name(name), book_price(price)
{
}
book::~book()
{
}
void book::display()
{
std::cout << "book name:" << book_name << std::endl;
std::cout << "book price:" << book_price << std::endl;
}
void book::set_price(double price)
{
book_price = price;
}
// main.cpp
#include "book.h"
int main()
{
book b("Grimm's Fairy Tales",49.9);
b.display();
b.set_price(45.9);
b.display();
}
缺点:
1、头文件暴露了私有成员;
2、接口和实现耦合,存在严重编译依赖性。当另一个库使用了这个库,而book库实现变了,头文件就会变,而头文件一旦变动,所有使用了这个库的程序都要重新编译,这会花费很多编译时间。
PIMP模式实现示例代码:
// book.h
#pragma once
#include <iostream>
class impl;
class book
{
public:
book();
book(std::string , double );
~book();
/*私有成员为指针,禁止使用C++默认浅拷贝*/
book(const book&) = delete;
book& operator=(const book&) = delete;
//移动拷贝构造
book(book&&);
book& operator=(book&&);
void display();
private:
std::unique_ptr<impl> pImpl;
};
// book.cpp
#include "book.h"
class impl
{
public:
impl() {}
impl(std::string name,double price);
~impl();
void display();
void set_price(double);
double get_price();
private:
std::string book_name;
double book_price;
};
impl::impl(std::string name,double price):book_name(name),book_price(price)
{
}
impl::~impl()
{
}
void impl::display()
{
std::cout << "book name:" << book_name << std::endl;
std::cout << "book price:" << book_price << std::endl;
}
void impl::set_price(double price)
{
book_price = price;
}
double impl::get_price()
{
return book_price;
}
book::book() :pImpl(std::make_unique<impl>())
{
}
book::book(std::string name,double price) : pImpl{ std::make_unique<impl>(name,price) }
{
}
book::~book() = default;
book& book::operator=(book&&) = default;
void book::display()
{
pImpl->display();
}
// main.cpp
#include "book.h"
int main()
{
book b("Grimm's Fairy Tales",49.9);
b.display();
}
2、特点
a、信息隐藏
在头文件中只有一个私有成员变量pImpl,用户无法从头文件获取到更多的信息,起到了一个信息隐藏的作用,同时访问book的display()方法,也无法直接查看到其具体实现,成功隐藏实现;
b、加速编译
接口和实现形成了一个松耦合,降低了编译依赖,当需要改动方法book的display(),例如:删除输出价格,只需要修改impl的display()方法即可,book.h头文件没有发生变动,不会重新编译。
c、更好的二进制兼容性
通常对一个类的修改,会影响到类的大小、对象的表示和布局等信息,那么任何该类的用户都需要重新编译才行。而且即使更新的是外部不可访问的private部分,虽然从访问性来说此时只有类成员和友元能否访问类的私有部分,但是私有部分的修改也会影响到类使用者的行为,这也迫使类的使用者需要重新编译。
而对于使用PIMPL手法,如果实现变更被限制在实现类中,那公有类只持有一个实现类的指针,所以实现做出重大变更的情况下,PIMPL也能够保证良好的二进制兼容性,这是PIMPL的精髓所在。
d、惰性分配
实现类可以做到按需分配或者实际使用时候再分配,从而节省资源提高响应。
3、注意
a、资源管理
尽可能避免使用原始指针类创建和释放(delete)实现类对象(pimpl对象),可以用std::unique_ptr 来管理实现类对象;如果确实需要实现类共享,可以用 std::shared_ptr来管理,但是unique_ptr实现上要比shared_ptr高效的多。
如果使用智能指针unique_ptr管理实现类对象,则需要手动在实现文件中定义共有类的析构函数。这是因为虽然unique_ptr和shared_ptr都可以在类型不完全的情况下定义其智能指针,但是unique_ptr其析构函数则需要具有持有类型的完全定义,而shared_ptr比较智能则没有这个限制。
b、拷贝语义
pImpl最需要关注的就是共有类的复制语义,因为实现类是以指针的方式作为共有类的一个成员,而默认C++生成的拷贝操作只会执行对象的浅拷贝,这显然违背了pImpl的原本意图,除非是真的想要底层共享一个实现对象。
1)禁止复制操作
将所有的复制操作定义为private或者delete;
2)显式定义复制语义
创建新的实现类对象,执行深拷贝。
三五法则:当定义一个类时,我们显式地或隐式地指定了此类型的对象在拷贝、赋值和销毁时做什么。一个类通过定义三种特殊的成员函数来控制这些操作,分别是拷贝构造函数、赋值运算符和析构函数。在较新的C++11标准中,为了支持移动语义,又增加了移动构造函数和移动赋值运算符,这样共有五个特殊的成员函数。
- 需要析构函数的类也需要拷贝和赋值操作
- 需要拷贝操作的类也需要赋值操作,反之亦然
- 析构函数不能是删除的成员
c、反向引用
实现类中的私有成员如果需要访问公有类的公共、保护的成员,就必须要能够引用到公有类对象,可以通过在impl中的这些函数都增加一个对公有类的引用或者指针:
pimpl->func(this, params);
4、缺点
a、pImpl->前缀
公有类在访问私有成员的时候都需要增加pImpl->前缀的方式,使用、阅读和调试不方便。
b、拷贝操作敏感
要么禁止拷贝操作,要么就需要自定义拷贝操作。
c、const保护失效
公有类的const只能保护指针值本身是否改变,而不再能进一步保护其所指向的数据。在较高版本C++(实测需要17及以上)可以使用std::experimental::propagate_const技术。
d、无法隐藏虚函数
如果虚函数覆盖了从基类继承的虚函数,则它必须出现在实际的派生类中。
e、增加间接层次
如果Pimpl中的函数需要调用公共类的public成员函数,要么就得增加间接调用,或者部分私有函数不放入Pimpl。
三、利用CPU Cache优化性能
先看下计算矩阵中所有元素的总和这两段代码:
// 按行遍历元素做计算
const int row = 10240;
const int col = 10240;
int matrix[row][col];
int TestRow() {
//按行遍历
int sum_row = 0;
for (int r = 0; r < row; r++) {
for (int c = 0; c < col; c++) {
sum_row += matrix[r][c];
}
}
return sum_row;
}
// 按列遍历元素做计算
int TestCol() {
//按列遍历
int sum_col = 0;
for (int c = 0; c < col; c++) {
for (int r = 0; r < row; r++) {
sum_col += matrix[r][c];
}
}
return sum_col;
}
在Visual Studio上debug断点计算耗时分别是315ms和949ms,发现行遍历的代码速度比列遍历的代码速度快很多。
可以看下存储器相关的金字塔图:
从下到上,空间虽然越来越小,但是处理速度越来越快,相应的,设备价格也越来越贵。
L1、L2、L3可以理解为CPU Cache就是CPU与主存之间的桥梁。
不同的处理器Cache大小不同,通常现在的处理器的L1 Cache大小都是64KB。CPU访问各个Cache的速度如下:
当CPU想要访问主存中的元素时,会先查看Cache中是否存在,如果存在(称为Cache Hit),直接从Cache中获取,如果不存在(称为Cache Miss),才会从主存中获取。Cache的处理速度比主存快得多。
所以,如果每次访问数据时,都能直接从Cache中获取,整个程序的性能肯定会更高。但是,CPU Cache这里还有个小问题,如下:
// 代码段1:
struct Point {
std::atomic<int> x;
// char a[128];
std::atomic<int> y;
};
void Test() {
Point point;
std::thread t1(
[](Point *point) {
for (int i = 0; i < 100000000; ++i) {
point->x += 1;
}
},
&point);
std::thread t2(
[](Point *point) {
for (int i = 0; i < 100000000; ++i) {
point->y += 1;
}
},
&point);
t1.join();
t2.join();
}
// 代码段2
struct Point {
std::atomic<int> x;
char a[128];
std::atomic<int> y;
};
void Test() {
Point point;
std::thread t1(
[](Point *point) {
for (int i = 0; i < 100000000; ++i) {
point->x += 1;
}
},
&point);
std::thread t2(
[](Point *point) {
for (int i = 0; i < 100000000; ++i) {
point->y += 1;
}
},
&point);
t1.join();
t2.join();
}
两端代码的核心逻辑都是对Point结构体中的x和y不停+1。只有一点区别就是在中间塞了128字节的数组。但它们的执行速度却相差很大,用vs跑耗时分别是4253ms、1647ms。明显,带128的比不带128的代码,执行速度快很多。
这里涉及到CPU多级缓存架构:
L1 Cache是最离CPU最近的,它容量最小,速度最快,每个CPU都有L1 Cache,见上图,其实每个CPU都有两个L1 Cache,一个是L1D Cache,用于存取数据,另一个是L1I Cache,用于存取指令。
L2 Cache容量较L1大,速度较L1较慢,每个CPU也都有一个L2 Cache。L2 Cache制造成本比L1 Cache更低,它的作用就是存储那些CPU需要用到的且L1 Cache miss的数据。
L3 Cache容量较L2大,速度较L2慢,L3 Cache不同于L1 Cache和L2 Cache,它是所有CPU共享的,可以把它理解为速度更快,容量更小的内存。
Cache Line可以理解为CPU Cache中的最小缓存单位。Main Memory-Cache或Cache-Cache之间的数据传输不是以字节为最小单位,而是以Cache Line为最小单位,称为缓存行。
目前主流的Cache Line大小都是64字节,假设有一个64K字节的Cache,那这个Cache所能存放的Cache Line的个数就是1K个。
回到上面的示例代码,如果x和y之间没有128字节的填充,它俩就会在同一个Cache line上。
代码中开了两个线程,两个线程大概率会运行在不同的CPU上,每个CPU有自己的Cache。
当CPU1操作x时,会把y装载到Cache中,其他CPU对应的的Cache line失效。
然后CPU2加载y,会触发Cache Miss,它后面又把x装载到了自己的Cache中,其他CPU对应的Cache line失效。
然后CPU1操作x时,又触发Cache Miss。
它俩就会是大体这个流程:
频繁的触发Cache Miss,导致程序的性能相当差。
而如果x和y中间加了128字节的填充,x和y不在同一个Cache line上,不同CPU之前不会影响,它俩都会频繁的命中自己的Cache,整个程序性能就会很高,这就是传说中的False Sharing问题。
总结:
- 写单线程程序,最好保证访问的数据能够相邻,在一个Cache line上,可以尽可能的命中Cache
- 写多线程程序,最好保证访问的数据有间隔,让它们不在一个Cache line上,减少False Sharing的频率
四、Qt实用技巧
1、QVariant万能方法
Qt的Model/View中,常用QVariant将data从model传递到delegate进行绘制;虽然QVariant已经自带了toString、toFloat等各种转换,但是还是不够。比如,有时候想在列表上绘制不同颜色的item,需要从QVariant转到QColor,但没有toColor这样的方法。这时,可以用QVariant的万能方法:
if (variant.typeName() == "QColor") {
QColor color = variant.value<QColor>();
QFont font = variant.value<QFont>();
QString nodeValue = color.name(QColor::HexArgb);
}
2、QStyle获取ScrollBar鼠标位置值
QStyle::sliderValueFromPosition(minimum(), maximum(), event->x(), width());
3、qSetRealNumberPrecision设置精度
当你用QString的toDouble转为double数据,然后qDebug打印的时候,会发现精度少乐(只剩三位)其实原始数据还是完整的精度,可以用qSetRealNumberPrecision设置精度位数:
QString s1, s2;
s1 = "666.5567124";
s2.setNum(888.5632123, 'f', 7);
qDebug() << qSetRealNumberPrecision(10) << s1.toDouble() << s2.toDouble();
4、去掉获取焦点后的虚边框
setStyleSheet("*{outline:0px;}");
5、防止QDialog阻塞后台消息
使用QDialog的时候会阻塞后台消息,可以通过下面的方法避免:
QDialog dialog;
dialog.setWindowModality(Qt::WindowModal);
6、Qt内置路径斜杠转换方法
文件路径中的斜杠随平台而异,在linux上一般是“/“,windows上是“\”。Qt其实内置斜杠转换,转成对应系统的路径:
QString path = "C:/temp/test.txt";
path = QDir::toNativeSeparators(path);
//输出 C:\\temp\\test.txt
QString path = "C:\\temp\\test.txt";
path = QDir::toNativeSeparators(path);
//输出 C:/temp/test.txt