004-QString

auther: abinng date: 2026-03-18 211:03 createDate:2026-03-18 21:03

从字符编码讲起

计算机字符编码起源于英语系国家,初始是 ASCII 编码,实现了 128 个符号的映射关系

但是后来非英语国家也对字符编码有需求,显然 ASCII 的 8bit 不够表示那么多状态,不够那么多映射关系,此时依旧沿用低 7 位标识英语系符号,使用多字节来进行编码,有了国标码,例如中国是 GBK

而后肯定有一个问题是,在中国使用 GBK 编码的文件,发送到了阿拉伯国家,编码格式变了,数据翻译成了另外的含义,不便于传输信息

所以有了 Unicode 编码,2Byte 表示所有符号,是一个统一符号编码。当然 2Byte 还是不太够,因为还有 emoji 表情等符号,后面还有其他编码,这里暂且不论

but, 英语系国家原本只用到了低 7 位的二进制,现在要使用 Unicode 编码就得数据长度翻倍且效果一致,这是定长编码的缺点。那就用变长编码(梦回哈夫曼编码…),用字符出现的概率对不同的字符进行长度适应,出现概率大的字符,编码较短,出现概率小的字符编码较长,这就是 UTF-8 编码

接下来去 VSCode 中进行一下实验,先下载一个插件 Hex Editor 用于查看文件的十六进制表示

创建一个文件输入 abc ,之后 Ctrl + Shift + P 输入 Hex 回车,就可以看到当前文件内容的十六进制表示,这里是 61 62 63

选中 a 对应的 61

file-20260319084105440

可以看到前 128 个字符,基本在各大编码里面都一样

当我们输入一个中文,如 abc中 ,发现:

file-20260319084105443

因为 VSCode 默认编码是 UTF-8 ,中文占三个字节

这里我们切成 GBK 编码,右下角找到 UTF-8 代表当前的编码,点一下,有两个选项

  • 通过编码重新打开:不改变文件底层数据内容,只改变翻译规则
  • 通过编码保存:改变文件底层数据内容,且改变翻译规则

这里点击通过编码保存,搜索 GBK ,当然 GB18030GB2312 也行,之后再去看十六进制编码,就是两字符了

file-20260319084105442

这次换一下,通过编码重新打开,随便换一个国家的编码,假设文件不做修改,直接用其他编码的翻译规则来看:

file-20260319084105439

这符号都看不懂,但 abc 依旧没变

对于解析不出来的符号,会用一个实心菱形嵌套问号来表示

不同国家的机器上,终端的编码都不同,可见编码的问题比较难解决,但是 Qt 是怎么做的呢??

存为 Unicode 编码,两个字节代表一个编码,在不同的机器上再进行编码的转换,达到跨平台的效果。相当于加了一层

终端乱码?为什么

可执行程序中的字符到终端

可执行程序,将字符流送到终端的解码器,最后显示出来

这里有一个疑问,终端的解码器是依据什么编码呢?

拿 Windows 举例,安装完之后会让选择时区/地区,会根据选择的结果来给终端定编码

例如:现在源码文件是 UTF-8 编码,之后送到终端的时候,是一个一个原封不动地传过去的,而终端的解码器是 GBK ,那么就会使用 GBK 的映射表进行解码,最后显示在终端上大概率是有乱码的

file-20260319090605105

试验一下:创建文件,写入

1
2
3
4
5
6
#include <iostream>

int main() {
std::cout << "hello你好" << std::endl;
return 0;
}

在该文件所在文件夹打开终端,g++ -o build hello.cppbuild.exe

发现输出是:hello和乱码组成的串,hello浣犲ソ

但是如果源文件就是GBK编码的格式,那么再往后的操作就会正常输出,并不会出现乱码

也就是说有很多时候出现的乱码,是因为源文件的编码和终端的编码不一致导致的

由此也可见,源文件的编码和终端的编码都在影响着跨平台,Qt实现跨平台就是通过无论什么源文件,当存入可执行程序的时候,存的是 Unicode 编码集,编译的时候就会结合我当前操作系统中的 Qt SDK 来处理编码的问题

Qt 中的字符,含义和传统的字符不一样了

操作一下

Qt creator 的默认编码是什么?—>最上面一行->编辑->preferences->文本编辑器->行为->往下翻就可以看到默认编码了,是 UTF-8

如果要改变本文件的编码,最上面一行->编辑->选择编码,之后选择目标编码

创建一个 Qt Console Application

测试一:编码问题

1
2
3
4
5
6
7
8
9
10
11
void test01() {
// 传统C/C++的字符流
const char *str1 = "hello你好";
std::string str2 = "hello你好";
// Qt的字符流
QString str3 = "hello你好";

cout << str1 << endl; // hello浣犲ソ
cout << str2 << endl; // hello浣犲ソ
qDebug() << str3; // "hello你好"
}

也可以打断点,看看实际存入的情况

file-20260319090805401

我们发现 QChar 和普通的字符好像确实不太一样,他是以 UTF16 两个字节来作为一个单位

现在源文件是UTF8编码,我们保存为GBK编码,再次运行,发现前两个是正常中文,最后一个又成了乱码,这是因为 QChar 默认认为传入的字符串是 UTF8 编码,我们可以通过构造函数来适配一下

1
QString str3 = QString::fromLocal8Bit("hello你好");

这时候我们的字符串 hello你好 是 GBK 编码,通过构造函数 fromLocal8Bit 的转换就可以了

这个 Local 就意味着我们现在操作系统终端默认的编码,也就是 GBK

而当我们又把源文件保存为 UTF8 编码时,用 fromLocal8Bit 来构造,就又会出问题了

其他构造函数可以去帮助手册中看

测试二:QString 底层与 COW

说一下 COW

Qt 中,由于控件众多,还有很多信号和槽的机制,要是用指针指来指去,对于开发者来说,维护比较困难,采用了 COW 的机制

当我们写类似如下代码时

1
2
QString s1 = "hello你好";
QString s2 = s1;

此时 s2 是和 s1 指向同一个空间的,除非我们修改了 s2,才会新拷贝出来一个空间并修改

QString 底层

QString 对外表现的,是 QChar 组成的流。但实际上它的内部结构不止包含 QChar ,还包含元属性

file-20260319094215190

测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* 查看QString的内部结构,以及COW效果 */
void test02() {
QString x1 = "12345";
qDebug() << "哈哈x1=" << x1 << " x1 data=" << x1.constData() << " x1 data ptr=" << x1.data_ptr().d_ptr();
QString x2(x1);
qDebug() << "哈哈x2=" << x2 << " x2 data=" << x2.constData() << " x2 data ptr=" << x2.data_ptr().d_ptr();
x2[1] = 'w';
qDebug() << "===============";
qDebug() << "哈哈x1=" << x1 << " x1 data=" << x1.constData() << " x1 data ptr=" << x1.data_ptr().d_ptr();
qDebug() << "哈哈x2=" << x2 << " x2 data=" << x2.constData() << " x2 data ptr=" << x2.data_ptr().d_ptr();
}

/* 输出:
哈哈x1= "12345" x1 data= 0x19c247b1ad0 x1 data ptr= 0x19c247b1ac0
哈哈x2= "12345" x2 data= 0x19c247b1ad0 x2 data ptr= 0x19c247b1ac0
===============
哈哈x1= "12345" x1 data= 0x19c247b1ad0 x1 data ptr= 0x19c247b1ac0
哈哈x2= "1w345" x2 data= 0x19c247b7870 x2 data ptr= 0x19c247b7860
*/
  • constData():这个是数据的起始地址
  • data_prt():这个是整个 QString 的起始地址

两个值差的绝对值,正好是 0x10 ,十六字节,说明 QString 前面的元属性占十六字节

第一段输出,s2 和 s1 的地址还相同,说明是指向同一块内存 第二段输出,发现 s2 和 s1 的地址不同了,说明触发了 COW

所以 Qt 中函数传参时,如果只是用来读,即使是值传递,代价也不大,因为有 COW ,按值传递传参的代价就仅仅是 “复制一个指针 + 增减一次引用计数”。如果用 const QString & 就会是复制一个指针,连引用计数都不用操作

COW 扩展

By Gemini

Qt 中大部分的数据类型,都是 COW 机制,而控件(如:QWidget, QPushButton, QLabel 等)以及像 QTcpSocket, QTimer, QThread 这样的核心功能类,它们都有一个共同的祖宗:QObject

QObject 及其所有的子类,不仅没有 COW 机制,而且根本不允许被拷贝

为什么这么设计? 因为这类对象代表的是一个独一无二的实体(身份)

  1. 状态与连接: 一个按钮可能有特定的父窗口,绑定了一堆信号和槽(Signals & Slots)。如果你“复制”了一个按钮,新按钮应该继承那些信号和槽吗?如果继承了,点击新按钮是不是要触发老按钮的逻辑?这会引发巨大的混乱。

  2. 对象树(Object Tree): Qt 通过父子树来管理内存。复制一个树节点在逻辑上是极其复杂的。

如果你去翻看 QObject 的源码,你会发现 Qt 直接把拷贝构造函数和赋值操作符给禁用了(使用了 Q_DISABLE_COPY 宏)。

1
2
3
// 这样的代码在编译时就会直接报错
QPushButton btn1;
QPushButton btn2 = btn1; // 编译错误!QObject 删除了拷贝构造函数

一个高频踩坑点

隐式共享的“分离(Detach)陷阱”

既然容器类(比如 QList)都支持 COW,那很多新手会遇到一个经典的性能陷阱。

当两个变量共享同一块数据时,只要其中一个执行了非 const 的操作,Qt 就会被迫进行深拷贝(这在 Qt 中称为 Detach 分离)。

1
2
3
4
5
QList<int> list1 = {1, 2, 3};
QList<int> list2 = list1; // 此时引用计数为 2,没有发生拷贝

// 陷阱来了!即使你只是想读取 list2 的第 0 个元素
int a = list2[0];

在上面这段代码中,list2[0] 调用的是非 const 的 operator[]。Qt 不知道你拿到这个引用之后是要“读”还是要“写”,为了安全起见,它会立刻触发 COW 进行深拷贝!这会导致极大的性能浪费。

正确的做法是使用 at(),它是 const 的,不会触发拷贝:

1
int a = list2.at(0); // 引用计数依然为 2,非常快!

QString 的使用

详细的 API 列表建议直接查阅 QtCreator 帮助文档:QString Class。这里主要总结高频核心操作和避坑指南。

1. 基础状态与查阅

  • 容量与长度

    • size()length():两者等价,返回的是 字符的个数(准确地说是 UTF-16 编码的 QChar 个数),不是字节数。例如 QString("哈哈哈aaa").size() 返回的是 6。
  • 安全访问

    • at(qsizetype i):只读访问。极力推荐使用,因为它是 const 的,不会触发隐式共享的 COW(写时复制)机制带来的深拷贝。
    • operator[]:如果非常量字符串使用 [] 读取,可能会意外触发 COW,造成性能浪费。
  • 区分

    • isNull():对象是否未被初始化。QString().isNull()true
    • isEmpty():内容长度是否为 0。QString("").isEmpty()true,但它的 isNull()false
    • 最佳实践: 日常开发中判断字符串有没有内容,一律使用 isEmpty(),涵盖了 isNull 的情况。

2. 拼接与插入 (增)

除了传统的 ++= 操作符外,QString 提供了更语义化的链式调用:

1
2
3
4
5
6
7
8
9
10
11
void test_append() {
QString str1("hello");
// 支持链式调用,非常优雅
str1.append('_').prepend("你好,");
str1 += "!!";
// str1 = "你好,hello_!!"

// 在指定索引位置插入
str1.insert(1, "们");
// str1 = "你们好,hello_!!"
}

3. 截断、截取与分割 (改)

这部分是处理文本数据时最常用的功能。

  • 头尾修剪 (Chop & Truncate)

    • chop(n)原地修改,从末尾砍掉 n 个字符。
    • chopped(n)非原地修改,返回一个砍掉尾部 n 个字符的新字符串(原字符串不变)。
    • truncate(n)原地修改,从索引 n 处截断(保留前 n 个字符,后面的全扔掉)。
  • 提取子串 (Mid, Left, Right)

    • left(n) / right(n):提取最左边 / 最右边的 n 个字符。
    • mid(position, n)极其常用!position 处开始,提取 n 个字符。如果不传 n,则一直提取到末尾。
  • 字符串分割 (Split)

    • split(分隔符):将字符串按规则切分成字符串列表 QStringList(本质上是 QList<QString>)。
1
2
3
4
5
6
7
8
9
void test_slice() {
QString data = "Name:Abinng:Age:25";
// 截取
QString name = data.mid(5, 6); // "Abinng"

// 分割 (解析 CSV 或特定格式数据时必备)
QStringList parts = data.split(":");
// parts = ["Name", "Abinng", "Age", "25"]
}

4. 查找与删除 (查、删)

  • 包含与检索

    • contains(str):是否包含某子串(可设置是否区分大小写)。
    • startsWith(str) / endsWith(str):判断开头或结尾。
    • indexOf(str):查找子串第一次出现的位置索引,找不到返回 -1。
  • 删除与替换

    • remove(position, n):从某位置开始删除 n 个字符。
    • replace(position, n, after):按位置替换。
    • replace(before, after)全局替换,将所有的 before 替换为 after
1
2
3
4
5
6
7
void test_replace() {
QString x = "Say yes!";
x.replace(4, 3, "no"); // x = "Say no!"

QString path = "C:\\Windows\\System32";
path.replace("\\", "/"); // 统一路径斜杠 path = "C:/Windows/System32"
}

常用案例

提取路径信息中的文件名,后缀名,所在目录信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 提取路径信息中的文件名,后缀名,所在目录信息
void test02() {
QString str1("/home/abinng/work/code.my.txt");
QString seq('/');
qsizetype last_pos = str1.lastIndexOf(seq);
QString str11 = str1.left(last_pos); // [0, last_pos)
qDebug() << str11; // "/home/abinng/work"
QString str12 = str1.right(last_pos); // (end_pos - last_pos, end_pos]
qDebug() << str12; // "/work/code.my.txt"
QString filename = str1.mid(last_pos + seq.size());
qDebug() << filename;
QString postfix = str1.mid(str1.lastIndexOf('.') + 1);
QString path = str1.left(last_pos + seq.size());
qDebug() << filename << postfix << path;
}

提取一段K-V对的value值,区间截取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 提取一段K-V对的value值,区间截取
void test03() {
QString str1("Content-Type: image/png\r\n/Content-length: 34789\r\n");
QString key = "Content-length:";
qsizetype start_pos = str1.indexOf(key);
qsizetype end_pos = str1.indexOf("\r\n", start_pos + key.size());
qsizetype num = end_pos - (start_pos + key.size());
QString len_str = str1.mid(start_pos + key.size(), num);
qDebug() << len_str;
bool flags = false;
int len = len_str.toInt(&flags);
if (flags) {
qDebug() << len;
}
}

编码转换类

这是 Static Public Members

如上面我们已经提到的一个 fromLocal8Bit ,还有很多类似的 from... 方法,用于匹配编码格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void test01() {
// 1. UTF8风格的字符数组,转换成QString
qDebug() << "===1===";
char buf1[] = "你好"; // UTF8
// for (int i = 0; i < strlen(buf1); ++i) {
// printf("%hhx\t", buf1[i]);
// }
// printf("\n");
QString str1 = QString::fromUtf8(buf1, strlen(buf1));
qDebug() << "str1:" << str1;

QString str2(buf1);
str2.append("好");
qDebug() << "str2.size:" << str2.size();

const char *result1 = str2.toUtf8().constData();
// for (int i = 0; i < strlen(result1); ++i) {
// printf("%hhx\t", result1[i]);
// }
// printf("\n");
}

数字转换类

就是 to... ,实际写代码的时候可以直接写一个 to 然后会自动提示很多方法,根据名字基本可以看出来是其作用,不行的话还是可以去文档中直接找或者上网/问ai的

还有从数字转换到 QString ,通过 QString::number 静态方法来实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void test02() {
/*
// 数字转字符串
QString s1 = QString::number(1024); // "1024"
QString s2 = QString::number(255, 16); // "ff" (转为 16 进制)

// 字符串转数字
bool ok;
int num = QString("123").toInt(&ok); // ok 会被置为 true,num = 123
double d = QString("3.14").toDouble();
*/
// 2.整数转换成QString进行存储,互换,是否成功
qDebug() << "===2===";
int num1 = 123;
qDebug() << QString::number(num1, 10);
QString str3("1223a");
bool sFlag = false;
int result2 = str3.toInt(&sFlag, 16);
if (sFlag) {
qDebug() << result2;
} else {
qDebug() << "toInt转换失败";
}
}

格式化类

C风格的是 sprintf ,QString 中是 asprintf ,可以进行格式化字符串

结合 arg 方法,可以格式化填充

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void test03() {
// 3.格式化QString类型
qDebug() << "===3===";
// 3.1 C风格format字符串 "My name is %s, I'm %d years old, %d is too young!\n"
// 按顺序 asprintf
// 3.2 Qt风格format字符串 "My name is %1, I'm %2 years old, %2 is too young!\n"
// 按位置 arg
const char *fmt1 = "My name is %s, I'm %d years old, %d is too young!\n";
QString result1 = QString::asprintf(fmt1, "abinng", 18, 18);
// qDebug() << result;
const char *fmt2 = "My name is %1, I'm %2 years old, %2 is too young!\n";
QString qfmt2(fmt2);
QString result2 = qfmt2.arg("abinng").arg(19);
qDebug() << result2;
}

字符串拼接优化

在 Qt 中拼接多段字符串,主要有三种方式:+ 操作符、arg() 函数、以及 Qt 特有的 % 操作符。它们在底层的性能表现有着天壤之别。

+ 操作符

该操作符有隐形开销

我们在直觉上觉得 + 号很自然,比如:

1
2
3
4
QString a = "Hello";
QString b = " ";
QString c = "World";
QString res = a + b + c;

底层发生了什么?

C++ 的 + 操作符是从左到右结合的。

  1. 先计算 a + b:Qt 必须分配一块新内存,把 “Hello “ 放进去,生成一个临时的 QString 对象
  2. 再计算 临时对象 + c:Qt 再次分配一块更大的新内存,把 “Hello World” 放进去。
  3. 最后把生成的字符串赋给 res,并销毁那个中间的临时对象。

结论: 如果你拼接 N 个字符串,底层会发生 N-1 次内存分配和拷贝,产生大量的临时对象。这在循环拼接巨大字符串时,简直是性能灾难。

arg()

该方法效率比较低

1
QString res = QString("%1 %2").arg(a).arg(c);

arg() 的好处是语义清晰,非常适合做多语言翻译。

底层发生了什么?

它的缺点是运行时解析开销。Qt 需要在运行时扫描整个字符串,寻找 %1%2 等占位符的位置,然后再进行替换和内存调整。它比 + 号稍微好一点点。

优化:QStringBuilder% 操作符

为了解决多段拼接的性能问题,Qt 引入了一个基于 C++ 模板元编程(Expression Templates)的机制。

1
2
3
#include <QStringBuilder> // 必须引入头文件

QString res = a % b % c;

底层发生了什么?

当你使用 % 时,a % b % c 并不会立刻生成任何字符串,也不会分配内存!

  1. 它在编译期生成了一个复杂的模板类(记录了参与拼接的所有对象)。
  2. 当它被赋值给 res 的那一瞬间,Qt 内部会先一次性计算出总长度(a.size() + b.size() + c.size())。
  3. Qt 直接在堆上分配一次足够大的内存。
  4. 将 a, b, c 的内容直接拷贝进这块内存中。

结论: 无论你拼接多少个字符串,使用 % 操作符永远只有一次内存分配,没有中间临时对象。这是真正的零开销抽象。

实战最佳实践

虽然 % 性能无敌,但把老代码里的 + 全改成 % 太累了。Qt 提供了一个宏,可以直接把所有的 + 号“劫持”成 % 的行为

在现代 Qt 工程中,你应该直接在构建脚本中全局开启这个宏:

  • 如果是 qmake (.pro 文件):
1
DEFINES += QT_USE_QSTRINGBUILDER
  • 如果是 CMake (CMakeLists.txt):
1
add_compile_definitions(QT_USE_QSTRINGBUILDER)

只要加了这一行代码,你工程里所有的 QString a + b + c 都会自动在底层替换为 QStringBuilder 机制