从 Java 到 Qt/C++ 的一些经验总结
Shaka / 2022-03-07
C/C++ 代码编译成可执行程序的过程
预处理
- 进行宏定义展开、头文件展开、条件编译,不检查语法。
- gcc -E demo1.c -o demo1.i
编译
- 检查语法,将预处理过的文件编译生成汇编文件
- gcc -S demo2.c -o demo2.s
汇编
- 将汇编文件生成目标文件(二进制代码文件,即.o 文件)
- gcc -c demo3.c -o demo3.o
链接
- 找到依赖的库文件,将目标文件链接为可执行程序。
- gcc demo4.c -o demo4 -L./ -llib1
- 通过gcc编译器让 demo4 链接自己制作的 lib1 动态库,并把 demo4 编译成可执行程序。
Java 代码编译和执行的过程
- 源文件由编译器编译成字节码
- 字节码由 Java 虚拟机解释运行。因为 Java 程序既要编译同时也要经过 JVM 的解释运行,所以 Java 也被称为半解释语言。
静态和动态
- 静态:在编译(编译 + 链接)阶段
- 动态:在执行阶段
#include <stdio.h>
#include <stdlib.h>
int main() {
int m, n;
int *p, *q;
scanf("%d%d", &m, &n);
p = (int*)malloc(sizeof(int) * m);
q = (int*)malloc(sizeof(int) * n);
return 0;
}
m、n、p、q 静态分配内存。局部变量,要占多大空间、往哪里放,在编译时就已经确定。 malloc 函数动态分配内存,在堆区分配内存,把地址赋值给 p、q。
进一步:静态和动态 + static 关键字
#include <stdio.h>
#include <stdlib.h>
void hehe() {
int k=0;
static int sum = 0;
k++;
sum++;
printf("%d %d\n", k, sum);
}
int main() {
int *p;
p = (int*)malloc(sizeof(int));
hehe();
hehe();
return 0;
}
k、sum 静态分配内存。局部变量,它们要占多大空间、往哪里放,在编译时就已经确定。 static 和静态、动态分配内存没关系,static 指 sum 变量的创建和销毁不会随着 hehe() 函数的调用而一次次创建销毁。
C 语言的内存分区
- 栈(Stack)
- 堆(Heap)
- 全局/静态 常量
- 文本(代码)Text(Code)
#include <stdio.h>
#include <stdlib.h>
int total = 0;//全局变量->全局/静态区
void hehe() {
static int he = 0;//静态局部变量,只创建一次,静态区
he++;
total++;
}
int main() {
int k = 3;//局部变量->栈, 3->文本区
char *str = "Hello";//str->栈 "Hello"->常量区
int *p = (int*)malloc(sizeof(int));//p->栈 malloc分配控件->堆
hehe();//函数每次结束,栈空间都会释放,而静态区不会
free(p);
return 0;
}
//文本区->放这个文件编译后的二进制码
动态库、静态库
库是写好的现有的,成熟的,可以复用的代码。
linux:
- 静态库:.a
- 动态库:.so
window:
- 静态库:.lib
- 动态库:.dll
- 静态库:是因为在链接阶段,会将汇编生成的目标文件.o与引用到的库一起链接打包到可执行文件中。因此对应的链接方式称为静态链接。
- 动态库:在程序编译时并不会被连接到目标代码中,而是在程序运行是才被载入。不同的应用程序如果调用相同的库,那么在内存里只需要有一份该共享库的实例。
静态库的特点:
- 1、使用静态库的时候,会将静态库的信息直接编译到可执行文件中(在编译时期完成的)
- 2、优点:当静态库被删除,对可执行文件没有影响
- 3、缺点:浪费内存空间。如果静态库被修改,可执行程序要重新编译
动态库的特点:
- 加载器在加载动态库时,操作系统会先检查动态库是否因为其它程序已经将这个动态库信息加载到了内存中。如果没有加载到内存中,操作系统会将动态库载入内存,并将它的引用计数设置为1;如果已经加载到内存,仅将动态库的引用计数加1。
动态库与模板
- 由于模板实例化是在编译时执行,如果在动态库中导出模板类(函数),因为实际的调用代码是在外部exe中,不在动态库本身中,所以在编译生成动态库的时候是不会生成实例的,这样在编译外部exe的时候就会报链接错误。
- 只有将导出库里面的模板函数或者模板类实例化,才能将实例化后模板函数的地址信息和模板类实例化的地址信息保存在导出库中。
//TemplateLib.h 使用动态库
#ifdef TEST_DLL_EXPORTS
#define TEST_API __declspec(dllexport)
#else
#define TEST_API __declspec(dllimport)
#endif
// 导出模板函数
template<typename T1>
TEST_API void fun1(T1);
template<typename T1,typename T2>
TEST_API void fun2(T1 , T2);
// 模板类
template<typename T,int size>
class TEST_API CTest
{
public:
CTest() {};
~CTest() {};
T* GetDataBuff() { return m_data;}
private:
T m_data[size];
};
// TemplateLib.cpp : 定义 DLL 应用程序的导出函数。
#include "stdafx.h"
#include "TemplateLib.h"
// 1.利用重载来实例化不同类型的模板,代码量大不说,基本上是重复的代码
// 2.库的设计者不知道用户会传入什么类型,也就是说设计者不可能实例化每一种类型的模板。
TEST_API void fun1(int var1) {}
TEST_API void fun1(char var1) {}
template<typename T1,typename T2>
TEST_API void fun2( T1 var1, T2 var2) {}
// 这个名字空间不作为导出使用,唯一作用是用来例化函数模板和类模板.
namespace implement_template_private
{
void implement_template()
{
int idata = 10;
char chr = 'x';
float fdata = 20.f;
UINT undata= 9;
char* str = "hello";
// 这种方式的实例化,代码量比重载方式少许多,但需运行一次该模板函数
// 也许在某些时候凭空运行这个函数是不合理的。
fun2(idata,chr); // int,char
fun2(undata,str); // UINT,char*
fun2<float,char*>(fdata,str); // float,char* 显示参数
// 导出类的实例化。
// 1.除了要实例化提供给用户使用的公有成员函数外,这里面还隐含的实例化了构造函数和析构函数.
// 2.注意这里每一个模板的实例化都是唯一的。
// 3.假如客户如果在项目中使用了CTest<char,30> impl_obj; 将会连接错误, 模板的参数列表必须完全匹配。
// 4.假如该模板类非常大,功能非常多,那么实例化工作可以想象是不堪忍受的。
// 5.库的设计者不知道用户会传入什么类型,也就是说设计者不可能实例化每一种类型的模板。
CTest<char,20> impl_obj;
impl_obj.GetDataBuff();
CTest<int,5> impl_obj2;
impl_obj.GetDataBuff();
}
};
- 总结:不建议在导出库中使用模板相关的技术,假如你能够确定用户在使用你设计的模板函数时,将传入哪些类型,设计者要将这些类型的模板一一实例化。
- 在动态库和静态库使用模板:https://blog.csdn.net/xiexievv/article/details/8500234
auto(C++ 11)
其核心在于类型推导,也就是让编译器根据等号右边的表达式来决定auto实际代表的类型。C++的auto只涉及到编译期的行为而不是运行期。
- 优点:
- 可以极大的缩短代码的长度。
- 缺点:
- 可能会降低代码的可读性。
- 可能会引入额外的性能开销。
如:
MyBigDataType& func();
...
auto value = func();
因为 auto 会移除表达式类型的引用属性,那么此时以上最后一行的行为就是拷贝构造一MyBigData实例,相信这不是此函数的实现者希望的。
- 注意:在引用类型、const 等类型上使用 auto 时要小心。
C++ 引用
参数传递:注意值传递和引用传递的区别。
#include <iostream>
using namespace std;
void fun(int &k) {
k = 2;
}
int main() {
int a = 1;
fun(a);
cout << a << endl;
return 0;
}
虚函数表
什么是虚函数表?
对于一个类来说,如果类中存在虚函数,那么该类的大小就会多4个字节,然而这4个字节就是一个指针的大小,这个指针指向虚函数表。所以,如果对象存在虚函数,那么编译器就会生成一个指向虚函数表的指针,所有的虚函数都存在于这个表中,虚函数表就可以理解为一个数组,每个单元用来存放虚函数的地址。
虚函数(Virtual Function)是通过一张虚函数表来实现的。简称为V-Table。在这个表中,主要是一个类的虚函数的地址表,这张表解决了继承、覆盖的问题,保证其真实反应实际的函数。这样,在有虚函数的类的实例中分配了指向这个表的指针的内存,所以,当用父类的指针来操作一个子类的时候,这张虚函数表就显得尤为重要了,它就像一个地图一样,指明了实际所应该调用的函数。
新的 for 循环(C++ 11)
int arr[5] = {1, 2, 3, 4, 5};
for (int x : arr) {
x += 100;
cout << x << ' ';
}
cout << arr[0];
- arr 到 x 是值传递,可以用 int &x,或者 auto &
lambda(C++)
定义一个匿名函数,还可以捕获外部一定范围内的变量。
auto f1 = [](int a, int b) -> int{return a + b; };
auto f2 = [](auto a, auto b) -> auto{return a + b; };
//编译器就根据 return 语句自动推导出返回值类型。
auto f3 = [](auto a, auto b){return a + b; };
int c = 9;
//按值捕获 c 变量,同时不捕获其他变量。
auto f4 = [c](auto a, auto b){return a + b + c; };
//捕获外部作用域中所有变量(按值捕获)
auto f5 = [=](auto a, auto b){return a + b + c; };
cout << f1(1, 2) << endl;
类型转换(C++)
int k;
k = 3.14;//在 Java 中会报错,在 C/C++ 编译器中正常(k==3)。
- Java 对类型转换更严格。
- C/C++ 编译器给了程序员太多自由:编译器认为,程序员可以这样写,程序员应该知道他在干什么,也应该为自己的行为负责。
指针?引用(Java)
Java:基本数据类型(int、long等)、引用数据类型(数组、类、接口)。
Student zs = new Student(); //Java,引用
Student *pzs = new Student(); //c++,指针
- 实质是一样的。
- 要注意 Java 中的值传递和引用传递问题,实质上 Java 还是值传递的,只不过对于引用数据类型时,值的内容是它的引用(指针)。
for 循环(Java)
int[] arr = new int[10];
for (int temp : arr) {
temp++;
}
for (Student s : ss) {
s.changeSomething();
}
lamda(Java)
(parameters) -> expression
或
(parameters) ->{ statements; }
- Lambda 允许把函数作为一个方法的参数(函数作为参数传递进方法中)
// 1. 不需要参数,返回值为 5
() -> 5
// 2. 接收一个参数(数字类型),返回其2倍的值
x -> 2 * x
// 3. 接受2个参数(数字),并返回他们的差值
(x, y) -> x – y
// 4. 接收2个int型整数,返回他们的和
(int x, int y) -> x + y
// 5. 接受一个 string 对象,并在控制台打印,不返回任何值(看起来像是返回void)
(String s) -> System.out.print(s)
理解封装
- 类:某一类东西
- 对象:某类东西的实例
- “封”:数据 + 操作
- 成员:成员变量、成员函数
- 属性,方法(服务)
- “装”:访问控制
- public/protected/private/…
构造和析构(C++)
- 构造函数、析构函数
- 初始化列表
- 在构造函数中用初始化列表的方式对成员变量进行初始化,效率会更高,因为只进行构造,少了一次赋值。
- 成员变量的初始化顺序与初始化列表中列出的变量的顺序无关,它只与成员变量在类中声明的顺序有关。
#include <iostream>
using namespace std;
class Note {
public:
int nid;
Note(int id) :nid(id) {}
};
class Student {
private:
int sid;
string name;
Note nt;
public:
Student(int sid, string name, int id) : sid(sid), name(name), nt(id) {//初始化列表,进行初始化构造
//写在这里的只能是赋值
}
}
对象的复制(C++)
- 注意浅拷贝、深拷贝。
- 默认生成的赋值函数、拷贝构造函数都是浅拷贝(值拷贝),成员变量中有指针变量时,需要自己重写(深拷贝)。
Java 类-与C++不同
- 没有析构
- 不支持默认参数
- 显示构造
对象的复制(Java)
Array a = new Array();
Array b = a;
其实是引用的复制。
Array b = new Array(a);
对象的复制(返回新对象的引用)
理解“继承”
- 目的:代码复用
- 代码复用:
- 组合(has-a):窗口、按钮、文本框
- 继承(is-a):人(id、姓名、性别)、学生(id、姓名、性别、学号)、教师(id、姓名、性别、教师号)
C++ 的继承和Java 的继承区别
- C++ 可以多继承
- Java 单继承
多态-需求是什么
多态:指为不同数据类型的实体提供统一的接口。
是否有需要使用基类指针(引用)指向(引向)派生类对象?
class People {
public:
void say() {
cout << "I 'm a person!" << endl;
}
};
class Student : public People {
public:
void say() {
cout << "I'm a student!" << endl;
}
};
int main() {
People *zs = new Student();
zs->say();
return 0;
}
- 这不是真需求,应该用 Student 指针,甚至是 Student 对象。
class People {
public:
void say() {}
};
class Student : public People {
public:
void say() {}
};
class Teacher : public People {
public:
void say() {}
};
void func(People *zs) { zs->say(); }
int main() {
Student zs;
Teacher ls;
func(&zs);
func(&ls);
return 0;
}
- 拿不同的派生类的对象干同一件事
- func() 和具体的派生类型没关系。
- 用相同形式的东西(指针或引用),在具体调用的时候去执行不同对象或实体的行为
- 动态联编:编译时不确定,运行时才确定。
Qt 隐式共享
- Qt 中的许多 C++ 类使用隐式数据共享来最大化资源使用并最小化复制。
- 当作为参数传递时,隐式共享类既安全又高效,因为只有一个指向数据的指针被传递,并且只有当函数写入数据时才会复制数据,即copy-on-write。
QPixmap p1, p2;
p1.load("image.bmp");
p2 = p1; // p1 and p2 share data
QPainter paint;
paint.begin(&p2); // cuts p2 loose from p1
paint.drawText(0,50, "Hi");
paint.end();
隐式共享对象的对象分配(使用 operator=())是使用浅拷贝实现的。
隐式共享大多发生在幕后;程序员很少需要担心它。但是,Qt 的容器迭代器与 STL 的容器迭代器具有不同的行为。
Qt doc:https://doc.qt.io/qt-6/implicit-sharing.html
QList
QList 类是提供动态数组的模板类。该类中的所有函数都是可重入的(可以由多个线程同时调用)。另外两个常用的顺序容器 QStack 和 QQueue 继承自 Qlist。
对于大多数应用程序, QList 是最好使用的类型。 它提供了非常快速的追加。 如果你真的需要一个链表,使用 std::list。
Qt 元对象系统
Qt 的元对象系统为对象间通信、运行时类型信息和动态属性系统提供了信号和槽机制。
QObject 类为可以利用元对象系统的对象提供基类 。
Q_OBJECT 宏用于启用元对象功能,例如动态属性、信号和插槽。
元对象编译器(moc)为每个 QObject 子类提供实现元对象功能所需的代码。
- 这 moc工具读取 C++ 源文件。 类声明 Q_OBJECT ,它会生成另一个 C++ 源文件,其中包含每个这些类的元对象代码。 这个生成的源文件是 #include 到类的源文件中,或者更常见的是,编译并链接到类的实现中。
Qt 对象应该被视为身份,而不是值。 因此,QObject和所有子类都禁用了拷贝构造函数和赋值操作符。
为了方便内存管理,QObject 的构造函数中会传入一个 Parent 父对象指针,当父对象析构的时候,这个子对象列表中的所有对象都会被析构,当析构子对象的时候,会自动从父对象的子对象列表中删除。
这种机制在 GUI 程序开发过程中是相当实用的。有一个很明显的现象就是我们会在窗口中new很多控件,但是却没有delete,因为在父控件销毁时这些子控件以及布局管理器对象会一并销毁。
C++中规定了析构顺序应该按照其创建顺序的相反过程。
先创建父对象再创建子类对象,并且在创建子对象时就指定父对象;
Qt 跨平台和 Java 跨平台
Qt 允许我们编写独立于平台的代码,其中相同的代码库可以在不同平台上编译和部署,而无需任何更改(理想状态)。
Java 运行在虚拟机上。
Java:在虚拟机上 一次编译到处运行,但虚拟机是平台各异的,执行代码格式统一。
Qt 程序的运行是建立在 Qt 框架上的,一次编码到处编译,但框架是平台各异的,编程接口统一。
java也为此付出了运行效率的代价。因为一般程序直接通过操作系统由 CPU 执行,而 java 语言需要先通过 JVM 再映射到操作系统里,最后由 CPU 执行,执行过程多了一步。
Qt平台并没有使用类似 JVM 的明显抽象层(但是原理类似,它底层封装了针对不同平台的类库,API之类的,只是这些都被上层做了封装,对开发者来说操作各种平台的接口都是一样的),因此在框架开发过程中处理各种平台问题的复杂性要超过java。