虚函数
虚函数的核心目的是:通过父类访问子类定义的函数
class A {
void foo();
virutal void fun();
};
class B : public A{
void foo();
void fun();
};
int main(){
A a;
B b;
A* p = &a;
p->foo();调用的是A的
p->fun();调用的是A的
p = &b;
p->foo(); 调用的是A的
p->fun(); 调用的是B的 //父类指明virtual,子类的指针调用这个方法。
}
注意在override时,函数签名要一致,而且const也要一致
virtual void show(xx);
void show(xx) const; //wrong
override关键字
virtual void show(xx);
void show(xx) override;
final关键字
virtual void show(xx) final; //不可被override
void show(xx) override; //compile error
静态绑定:在类不含有虚函数的情况下,编译器在编译期间就会把函数的地址确定下来,运行期间直接去调用这个地址的函数。
事实上,类的成员变量和成员函数是分离的,所以子类在调用override的函数时,会动态绑定决定调用父类的还是子类的。
虚表:在含有虚函数的类编译期间,编译器会自动给这种类在起始位置追加一个虚表指针,称为vptr,vptr指向一个虚表vtbl,虚表中存储了实际的函数地址.
注意:多继承里有多个虚表指针
一个结构体有一个int一个char,sizeof是多少
type |
size(字节) |
char |
1 |
bool |
1 |
short |
2 |
long |
4 |
int |
4 |
float |
4 |
double |
8 |
long long |
8 |
int 4个字节,32bit,最大01^31 = 2^31 - 1。最小为10^31 = -2^31
int用补码表示,补码 = 反码 + 1
原码转补码:正数相同,负数除了符号位取反+1
补码转原码:正数相同,除了符号位取反+1
1 + 31个0 → 1 + 31个1 + 1 → 1+ 1 + 31个0 → -2^31
内存对齐
现代计算机中内存空间都是按照 byte 划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但是实际的计算机系统对基本类型数据在内存中存放的位置有限制,它们会要求这些数据的首地址的值是某个数k(通常它为4或8)的倍数,这就是所谓的内存对齐。
尽管内存是以字节为单位,但是大部分处理器并不是按字节块来存取内存的.它一般会以双字节,四字节,8字节,16字节甚至32字节为单位来存取内存,我们将上述这些存取单位称为内存存取粒度.
内存对齐的原因:某些计算机系统不能访问任意地址上的任意数据。使用内存对齐可以提高CPU访问效率
- 基本类型的对齐值就是其sizeof值;
- 结构体的对齐值是其成员的最大对齐值;
//32位系统
#include<stdio.h>
struct{
int i;
char c1;
char c2;
}x1;
struct{
char c1;
int i;
char c2;
}x2;
struct{
char c1;
char c2;
int i;
}x3;
intmain()
{
printf("%d\\n",sizeof(x1));// 输出8
printf("%d\\n",sizeof(x2));// 输出12
printf("%d\\n",sizeof(x3));// 输出8
return 0;
}
extern C
extern C用于在C++中混合编译C语言
C和C++的编译规则不一样,主要区别体现在编译期间生成函数符号的规则不一致。在实现函数重载时很重要。
//func.c
int function() {
}
//main.cc
extern int function();
int main()
{
function();//错,因为在c里的function函数符号和C++标准不一样,链接时找不到
}
inline函数
引入inline的原因: 为了解决一些频繁调用的小函数大量消耗栈空间的问题。因为每次调用函数都要在栈里保存函数调用和返回地址,太消耗内存。
使用注意:
- 函数内的代码必须简单,不能包含复杂的结构控制语句例如while、switch,并且函数本身不能直接递归调用。
- inline函数仅仅是对编译器的建议,能否真正内联取决于编译器
- 最好将inline定义在头文件里,这样省去为每个文件实现一次的麻烦
- 定义在类中的成员函数缺省都是内联的,如果在类中未实现函数定义,在类外需要加上inline。(缺省就是默认的意思)
class A
{
public:
void Foo(int x, int y);
}
inline void A::Foo(int x, int y){}
- inline必须与函数定义放在一起才行,只与函数声明放在一起不行
inline void Foo(int x, int y); // inline 仅与函数声明放在一起
void Foo(int x, int y){}
---
void Foo(int x, int y);
inline void Foo(int x, int y) {} // inline 与函数定义体放在一起
- 慎用内联函数,内联函数是以代码膨胀为前提
前向声明
优点 :
- 没有必要的#include会增加编译时间
- 混乱随意的#include可能导致循环#include,可能出现编译错误.
定义:声明一个类而不定义它,称为不完全类型,不完全类型只能用于定义指针,不能用于定义对象,可以用于声明该类型作为形参类型或返回类型的函数。
class A;
class B
{
public:
A* m_a; //必须是指针,不能是对象
A& m_a; //可以
A A_test(A &); //可以
}
重载、隐藏、重写(覆盖)三者的区别?
重载 : Overload 同名函数,具有不同参数列(类型,顺序,个数),根据参数列表决定调用那一个,重载不关心返回类型。
隐藏 : 子类屏蔽了与其同名的基类函数,只要同名就会隐藏(不管是否参数相同)
重写(覆盖) Override: 子类存在重新定义的函数,其函数签名和基类一致,只有函数体不同。子类对象调用重写方法时会调用自己的方法而不是父类的方法。
- 重载为什么改变参数就可以实现调用不同的函数?
C++在编译时会对函数进行重命名,保证函数名唯一性 - 构造函数可以被重载么?析构函数呢?
构造函数可以,析构函数不能。析构函数只能有一个,而且不能带参数
new和malloc的区别
malloc差,new好
malloc |
new |
差 |
好 |
堆区 |
自由存储区 |
返回void*,需要强转 |
返回对象类型的指针 |
需要指定内存大小 |
不需要指定 |
分配内存失败返回NULL |
分配内存失败抛出bac_alloc异常 |
内存不够可以重新分配 realloc |
不可以重新分配 |
不调用构造函数/析构函数(new一些自定义类) |
调用(new)构造(delete)析构函数 |
不能初始化数组元素对象 |
初始化数组元素对象 |
malloc,free不能被重载 |
operator new /operator delete可以被重载 |
- 自由存储区?
在C++中,内存区分为5个区,分别是堆、栈、自由存储区、全局/静态存储区、常量存储区。
malloc从堆上,new从自由存储区上
对于大多数编译器,自由存储区就是堆区,但也可以通过重载new自己实现自由存储区,例如存放在静态存储区 - 静态存储区和动态存储区?
静态存储区:全局变量和静态变量,在编译时就分配好了。
动态存储区:堆,栈,在在程序执行时分配的内存。(栈默认大小1M) - new operator和operator new和placement new的区别?
new即new operator
Foo* p = new Foo();
调用new时编译器做了如下操作
- 调用operator new分配内存,大小为Foo所占内存大小
- 调用构造函数
- 返回指针
operator new可以重载,分为全局的operator new和类内的operator new。
class Test
{
public:
// 在类中重载operator new
void* operator new(size_t size)
{
printf("operator new called\\n");
return ::operator new(size);
}
}
void* operator new(size_t size) //全局重载operator new
{
printf("global new\\n");
return malloc(size);
}
placement new 在第二步调用构造函数时,使用placement new实现。即在取得了一块可以容纳指定类型对象的内存之后,在这块内存上构造一个对象。
char buffer[sizeof(MyObject)];
p = new(buffer) Foo();
保持一块内存(预先定义好的),反复构造析构,这样可以省略中间的多次分配内存。
- C++对象实例化的时候使用new关键字和不适用new关键字的区别是什么?
用new在堆区,不用new在栈区。用new需要用指针接受
面向对象的三大特征
面向对象的三大特征:封装、继承和多态。
封装指的是将程序实现的各种数据和方法封装到一个类的内部,从而限制外界对这些数据和方法的访问,使其只能通过类的公共接口进行访问和操作。
C++实现了对数据的保护,使得其他函数和对象无法随意访问和修改类的私有成员,从而确保程序的可靠性和安全性。同时,类的成员函数则可以通过类的公共接口来操作这些私有成员,使得使用者无需关心类内部的具体实现细节,只需要调用公共接口即可完成相应的功能,从而提高了代码的可读性和可维护性。
多态就是说同一个名字的函数可以有多种不同的功能。分为编译时的多态和运行时的多态。编译时的多态就是函数重载,包括运算符重载,编译时根据实参确定调用哪个函数。运行时的多态则和虚函数、继承有关。
- 多态是怎样实现的
利用虚函数表,先构建一个父类,然后在父类的构造函数中会建立虚函数表,也就是一个储存虚函数地址的数组,内存地址的前四个字节保存指向虚函数表的指针,然后当多个子类继承父类之后,主函数中可以通过父类指针调用子类的继承函数。
虚函数表属于类(而不是对象,所有对象共享),也属于它的子类。虚函数表由编译器在编译时生成,保存在.rdata只读数据段。 - 子类的多态函数是怎么调用的
因为每个子类都继承并设置了自己的虚函数表,每次用用父类指针创建新子类时就会出现,从而最终调用自己的表。 - 怎么知道多态时,指向哪个虚函数?
定义的父类指针new出哪个子类就是指向哪个子类的虚函数。
指针和引用的区别
- 引用必须定义时初始化,不能像指针一样 int *a;必须int &b = a;
- 引用不可以改变指向
- 指针可以有多级,int **,但引用只能有一级 int &&代表右值引用
- 指针的++,--代表下一个数据,而引用的++,--代表数据本身的加减
- sizeof 引用得到的是指向变量的大小,而sizeof指针得到的是指针的大小
- 当指针和引用作为函数参数的时候,指针传递参数会生成一个临时变量,引用传递的参数不会生成一个临时变量。
int &b=a; 这里&a,&b取址相同,并不代表引用b不占用内存,而是系统自动将&b转换成对b中内容的读取。而b里面保存的是a的地址。
值传递,指针传递,引用传递的区别
值传递:
形参是实参的拷贝,改变形参的值并不会影响外部实参的值。从被调用函数的角度来说,值传递是单向的(实参->形参),参数的值只能传入,不能传出。当函数内部需要修改参数,并且不希望这个改变影响调用者时,采用值传递。
指针传递:
形参为指向实参地址的指针,当对形参的指向操作时,就相当于对实参本身进行的操作
引用传递:
形参相当于是实参的“别名”,对形参的操作其实就是对实参的操作,在引用传递过程中,被调函数的形式参数虽然也作为局部变量在栈中开辟了内存空间,但是这时存放的是由主调函数放进来的实参变量的地址。被调函数对形参的任何操作都被处理成间接寻址,即通过栈中存放的地址访问主调函数中的实参变量。正因为如此,被调函数对形参做的任何操作都影响了主调函数中的实参变量。
用过哪些标准模版库(STL)
C++ stl包括
- 容器container
vector, map,unordered_map, set,unordered_set, list, stack, queue,string,deque,priority_queue - 迭代器iterator
迭代器用于在一个对象群集的元素上进行遍历动作。 - 算法algorithm
对容器中元素进行处理
如sort(), swap(),for_each()等 - 仿函数
(70条消息) C++11——仿函数原理及使用场景仿函数原理与应用山河君的博客-CSDN博客
class print {
public:
print(int j) : m_j(j)
{
cout << "This is print" << endl;
}
public:
void operator()(int i) //定义operator()
{
if(i < m_j)
cout << i << endl;
}
public:
int m_j;
};
int main(int argv, char* argc[])
{
vector<int> it = { 1,2,3,4, 6, 7, 8 };
for_each(it.begin(), it.end(), print(5));
return 0;
}
仿函数一大用途是配合STL进行使用,用于方便模板类和模板函数
可以通过重载operator(),例如operator(int a), operator(double b), operator(string c)
这样可以应对多种类型的参数。
- vector的扩容机制
- vector拷贝方式
//1
vector<int> v2(v1);
//2
vector<int> v2;
v2.assign(v1.begin(), v1.end());
//3
vector<int> v2;
v2.swap(v1); //交换两个容器内容
左值和右值
一句话:能取地址的是左值,不能取地址的是右值
左值引用与右值引用:&和&&
int a = 10; //a是左值,10是右值
int &b = a; //b是左值引用
int &&c = a; //c是右值引用,不能直接引用到左值上,错
int &&d = std::move(a); //move将a从左值变成了右值,d为右值引用
move的作用:将左值变成右值 move只有这一个作用,没有移动内容的作用
右值引用是左值还是右值?不一定,有名字的是左值,没名字的是右值
int a = 10;
std::move(a); //这是右值,返回值没名字,返回的是右值引用int &&
int &&b = std::move(a); //这是左值,b有名字,b是右值引用但是是左值
void lvalue(string & s);//只能接受左值
void rvalue(string && s);//只能接受右值,s是右值引用,s是左值
void lrvalue(const string& s);//可以接受左值和右值
右值引用最重要的应用场景:移动语义
假设我实现了Array类
class Array {
public:
Array(const Array& temp_array) {
size_ = temp_array.size_;
data_ = new int[size_];
for (int i = 0; i < size_; i ++) {
data_[i] = temp_array.data_[i];
}
}
// 深拷贝赋值
Array& operator=(const Array& temp_array) {
delete[] data_;
size_ = temp_array.size_;
data_ = new int[size_];
for (int i = 0; i < size_; i ++) {
data_[i] = temp_array.data_[i];
}
}
~Array() {
delete[] data_;
}
Array(const Array& temp_array) {
size_ = temp_array.size_;
data_ = new int[size_];
for (int i = 0; i < size_; i ++) {
data_[i] = temp_array.data_[i];
}
}
// 深拷贝赋值
Array& operator=(const Array& temp_array) {
delete[] data_;
size_ = temp_array.size_;
data_ = new int[size_];
for (int i = 0; i < size_; i ++) {
data_[i] = temp_array.data_[i];
}
}
Array(Array&& temp_array) { //移动语义,接受一个右值,把右值里的东西挪到这里来(移动构造函数)
data_ = temp_array.data_;
size_ = temp_array.size_;
// 为防止temp_array析构时delete data,提前置空其data_
temp_array.data_ = nullptr;
}
~Array() {
delete[] data_;
}
private:
int data_*;
int size_;
}
对于Array ,可以如下使用
Array a;
Array b(std::move(a)); //把a里的东西挪到了b里,a变成空了
在stl容器里,可以如下使用
void push_back (const value_type& val);void push_back (value_type&& val);//函数签名如上,分为const左值,和右值。如果传进去左值,会copy一份放到vector里,如果传进去右值,会把原来的内容移到vector里
vector<string> v;
string a = "1234";
string &&b = std::move(a);
v.push_back(a); //a为左值,
v.push_back(b); //b为右值引用,但是b为左值,所以a不变
v.push_back(std::move(a));//push_back一个右值,代表把a的内容移到了v里,所以a变成空
所以移动语义通常应用于:在操作后原数据可以不要的情况
unique_pointer独享资源,所以它是move-only的,只可以move不可以copy
何时自定义deleter
当智能指针创建时是以数组形式(注意make_shared不可以使用自定义deleter)
shared_ptr<int> a(new int[10])
当智能指针指向对象没有自定义的dtor时,例如c程序。
智能指针有几种,区别是什么
shared_ptr, unique_ptr, weak_ptr
shared_ptr :共享所有权,引用计数法,同一个内存空间每多一个指针指向计数就+1。
unique_ptr :独占所有权,只能有一个指针指向,move-only,不能复制只能转移所有权。
weak_ptr :只能指向内存空间,但没有所有权,不增加引用计数,和shared_ptr联合使用避免循环引用。
- shared_ptr底层实现
引用计数法,每多一个sptr指向同一个内存,其计数+1,当指针作为形参传递时,计数+1,退出函数作用域计数-1,当计数=0时释放指针。 - 循环引用的例子:有A,B两个类,两个类内分别定义了对方类的智能指针。在主函数里首先定义两个类的智能指针,然后分别把两个指针赋予对方的成员指针里,就形成了循环引用。
- weak_ptr有什么用:weak_ptr可以用来观察shared_ptr,可以看到shared_ptr计数数量和是否已经释放。
- new和智能指针有什么区别
作用范围上的区别:尽量不要使用new而是智能指针,智能指针的作用范围是全局的,而new出的指针是局部的。
lambda函数
[](int a, int b)->bool{return xx;};
[]可以指定lambda函数的函数体可以使用哪些外部变量,即捕获变量。
[] : 不捕获任何外部变量
[=] : 值传递的方式捕获所有外部变量
[&] : 引用方式捕获所有外部变量
- lambda的底层实现
相当于创建了一个lambda匿名类,重载了operator()函数
class lambda
{
private:
int a;
int b;
public:
lambda(int a, int b) : a(a), b(b);
bool operator()(int x, int y) {return a + b > x + y;}
}
C++类内三种权限
public : 公有,可被任意成员函数访问
protect : 可以被本类/子类成员函数访问
private : 只能被本类成员函数访问
static 关键字
static用法主要体现在两方面
面向过程的static : 静态全局变量,静态局部变量,静态函数
面向对象的static : 静态成员变量,静态成员函数
面向过程的static :
静态全局变量
静态全局变量没有显示初始化则会被初始化为0
静态全局变量在该文件内可见,在文件外不可见
静态局部变量
静态局部变量分配在全局数据区
当程序运行到该对象声明时,会首次初始化,此后再运行到该对象声明时不会进行初始化
静态函数
使某个函数只在一个源文件中有效,不能被其他源文件所用
注意,非static函数默认是extern的
面向对象的static
静态成员变量
是类所有对象共享的
静态成员变量必须初始化,且只能在类外体进行初始化
class A
{
public:
static int a;
}
int main() {
cout<<A::a<<endl; //错,没有初始化
}
-----
class A
{
public:
static int a = 1; //错,在类内初始化
}
-----
class A
{
public:
static int a;
}
int A::a = 1;//对,在类外初始化,初始化不能加static
静态成员函数
静态成员函数可以在对象未实例化时调用。
静态成员函数中没有隐含的this指针,只能操作类内的静态成员
- 静态成员函数可以设置为virtual吗?
不能,static成员不属于任何类对象,所以加virtual也没有意义。静态成员函数没有this指针,无法访问vptr,(vptr是一个指针,只能用this访问,指向保存虚函数地址的vtable)
const
const作用:被他修饰的值不能改变,是只读变量。必须在定义的时候就赋初值。
const int a = 10; 或 int const a = 10;
底层const/常量指针
int temp = 10;
const int* a = &temp; //const 指针,常量指针,底层const
int const* a = &temp;
不能通过指针改变指向的值,但是可以直接修改。
*a = 100; //wrong
temp = 100; //ok
顶层const/指针常量
int temp1 = 10;
int temp2 = 12;
int * const p = &temp1;//指针 const
指针指向的地址不能改变
p = &temp2; //wrong
*p = 100; //ok
常指针常量
const int* const p = &temp;
既不能通过指针修改指向的值,又不可以改变指向的地址
区分顶层const和底层const的区别
底层const的指针不能赋给非底层const,只能赋给底层const
const_cast只能将底层const指针变为普通指针,其余指针不行。
volatile
volatile声明的变量,不会被编译器优化,告诉编译器每次操作该变量时一定要从内存中真正取出,而不是使用已经在寄存器
中的值。
volatile int i = 10;
//thread1
int a = i;
//thread2
int b = i;
在多个线程中都要用到一个变量,且这个变量的值会改变时,需要声明为volatile,防止一个访问内存里的值,一个访问寄存器里的值。
template
//模板函数格式
template <typename T1, typename T2, typename T3, ..>
返回值 函数名(形参) {
}
template<typename T> void swap(T &a, T&b) {
T temp = a;
a = b;
b = temp;
}
//类模板(类的声明)
template <typename T1, typename T2>
class Point{
public:
Point(T1 x, T2 y) : m_x(x), m_y(y){}
T1 getX() const;
void setX(T1 x);
T2 getY() const;
void setY(T2 y);
private:
T1 m_x;
T2 m_y;
}
//类模板(类的定义)
template<typename 类型参数1 , typename 类型参数2 , …>
返回值类型 类名<类型参数1 , 类型参数2, ...>::函数名(形参列表){
//TODO:
}
template<typename T1, template T2>
T1 Point<T1, T2>::getX() const
{
return m_x;
}
深浅拷贝
当类持有动态分配的内存,指向其他数据的指针时,需要深拷贝,保证拷贝的类里指针指向自己的空间。这样做的结果是原有对象和新对象所持有的动态内存相互独立.
抽象类
什么是抽象类:带有纯虚函数的类叫做抽象类
接口描述了类的行为和功能,无需完成类的特定实现
C++接口通过抽象类来实现,实现抽象类的目的是给其他类一个可以继承的适当基类。抽象类本类不能用于实例化对象,只能作为接口(基类)使用。
继承抽象类的子类必须override所有纯虚函数(实现所有接口)
class Pet
{
public:
virtual void func() = 0; //加virtual 和 = 0,就是纯虚函数
}
int main() {
Pet pet;//Wrong, 抽象类不能实例化
}
抽象类也有构造函数,析构函数,可以被继承
空类
C++的空类会默认生成哪些成员函数
如果只声明一个空类,而不生成一个对象,那么编译器不会为这个空类产生任何成员函数。
如果定义了一个对象,那么编译器会生成8个成员函数。
class Empty {
}
等价于
class Empty {
public:
Empty(){}
~Empty(){}
Empty(const Empty &rhs); //拷贝构造
Empty& operator=(const Empty &rhs); //拷贝赋值运算
Empty* operator&(); //取址运算
const Empty* operator&() const; //const版本取值运算
Empty& operator=(Empty &&); //移动赋值
Empty(Empty&&); //移动构造
}
int main() {
Empty e1(e2); //拷贝构造
Empty e1 = e2; //拷贝构造(e1不存在,e2存在,要创建e1)
e1 = e2; //拷贝赋值函数(e1和e2都存在)
Empty *pe1 = &e1; //取址
const Empty *pe2 = &e2; //const取址
Empty e1;
Empty e2 = std::move(e1); //移动构造
e2 = std::move(e1); //移动赋值
}
- 拷贝构造函数什么时候会被调用
//一个对象以值传递的方式传入函数体
void Case1(Empty e); //会调用拷贝构造
void NotCase(Empty &e); //不会调用拷贝构造
//一个对象以值传递的方式从函数返回
Empty Case2(){}; //会调用拷贝构造(然而会被编译器优化掉)
//一个对象需要通过另一个对象进行初始化
Empty e2 = e1;
Empty e2(e2);
- 赋值函数什么时候会使用
如果一个类数据成员包含指针时,有两种处理需求
- 复制指针对象 - 一般使用拷贝构造
- 引用指针对象 - 一般使用赋值函数
- 拷贝赋值如何实现
- 首先把原有对象的资源释放掉
- 检查两个对象是否为一个对象,如果是,则直接返回
- 例子
String::String(const String&other)
{
cout<<"拷贝构造调用"<<endl;
m_string = new char[strlen(other.m_string) + 1];
strcpy(m_string, other.m_string); //目的是拷贝一份新的
}
String& String::operator=(const String& other)
{
cout<<"拷贝赋值函数调用"<<endl;
if(this == &other)
{
return *this;
}
delete []m_string; //先释放掉原有的内存
m_string = new char[strlen(other.m_string) + 1]; //然后再复制一份
strcpy(m_string, other.m_string);
return *this;
}
- C++ 3 5 0原则
3原则
析构函数(~Empty),拷贝构造(Empty(const Empty& rhs))和拷贝赋值Empty& operator=(const Empty& rhs)函数,如果用户自定义了其中一个,就要把这三个都自定义了。
因为如果定义了其中一个,说明有的资源需要自己释放,不能靠编译器生成的释放,因此需要把这三个都自己定义一遍。 拷贝构造,拷贝赋值防止浅拷贝。或者设置成 = delete
5原则
C++ 11增加了移动构造函数和移动赋值运算符
这五个函数,只要用户自定义了一个,其余几个都要自定义
0原则
如果不需要自己实现析构函数,那就都别实现
拷贝构造能否使用值传递(应该是const引用):不能,如果是值传递,调用拷贝构造时会先将实参传给形参,这时会进行一次拷贝构造,因此会造成无限递归循环。
写时拷贝
写时拷贝就是延迟版的深拷贝,使用引用计数实现,一块空间被多个指针管理,当某一个指针想要改变其内容时,就要为它单独开辟一块空间。
初始化列表
类对象的构造顺序如下
- 先构造初始化列表
- 然后进行构造函数里的计算
必须使用初始化成员列表的情况
- 需要初始化的数据成员是对象(如果没有无参构造函数,那么必须手动初始化)(以及通过显示调用父类构造函数对付类成员初始化)
- 需要初始化const修饰的类成员或初始化引用成员数据
- 子类初始化父类的私有成员
class PA {
PA(int a) : pa(a){}
private:
int pa;//私有成员
}
class A: public PA{
public:
B b;
const int ca;
int &ra;
A() : b(xxx), a(xxx), ra(xxx), PA(xxx);
}
内存是怎么被分配的
分为
- 静态区/全局区 : 存放静态,全局变量
- 常量区 :存放常量
- 堆区 :
- 栈区
四种类型转换符
static_cast
最常见的转换符,用于把int转换为float之类的
static_cast 没有运行时类型检查来保证转换的安全性,需要程序员来判断转换是否安全。
static_cast还可以用来在基类和派生类之间转换
派生类包含基类,所以派生类可以用static_cast向上转为基类。
向下转是不安全的
可以用于将void指针转换为具体类型的指针
有转换构造函数或者类型转换函数的类与其他类型之间的转换
例如double 转Complex(转换构造函数),Complex 转double(类型转换函数)
int a = 10;
auto b = static_cast<float>(a);
---
Derive* d = new Derive();
Base* b = static_cast<Base*>(d); //向上转
const_cast
该运算符用于修改expression的const和volatile属性
const_cast只能将底层const变为普通指针,底层const就是常量指针,不能通过这个指针直接修改值。
int a = 10;//这里如果不是const常量,那么在line4 就会把a的值改为100
const int* pa = &a;
int* ppa = const_cast<int*>(pa);
*ppa = 100;//
---
const int a = 10; //如果声明a为const常量,那么*ppa也不能修改a的值
//因为const就是不能修改的,此时*ppa为undefined behaviour,C++不对结果负责。
reinterpret_cast
可以认为是 static_cast 的一种补充,一些 static_cast 不能完成的转换,就可以用 reinterpret_cast 来完成,例如两个具体类型指针之间的转换、int 和指针之间的转换。非常简单粗暴,但是风险很高
int main() {
int* p = new int(5);
uint64_t p_val = reinterpret_cast<uint64_t>(p); //用于将指针转换为整数
cout << "p_val:" << hex << p_val << endl;
}
dynamic_cast
类层次转换。
在向上转时,与static_cast一样,是安全的。
在向下转时,会进行类型检查
dynamic_cast是运行时处理的,其他三种都是在编译期完成的。
dynamic_cast转换成功会返回指向类的指针或引用,转换失败会返回nullptr
使用dynamic_cast转换时,基类一定要有虚函数,否则编译不通过。这是因为运行时类型检查需要运行时类型信息,而这个信息存在类的虚函数表里,只有定义了虚函数的类才有虚函数表。
转换总结
- static_cast:基本类型转换,低风险;
- dynamic_cast:类层次间的上行转换或下行转换,低风险;
- const_cast:去 const 属性,低风险;
- reinterpret_cast:转换不相关的类型,高风险
RTTI 运行时类型检查
只有包含虚函数的类才会启用RTTI机制
转换构造函数:
对于trival的类型转换,例如double->int, 编译器知道怎么做。
但是对于复杂类型的转换,例如double->Complex,编译器不知道,需要手动写。
转换构造函数,就是带一个参数的普通构造函数,但是有特殊的地方
class Complex {
public:
Complex(double b) { xx }; //一个参数的转换构造函数
}
Complex c1 = c2 + 10.12; //这里的10.12会自动转成Complex类
如果不想要转换构造函数生效,需要在前面加上explicit
class Complex {
explict Complex(double b) {xx};//expilict,禁止自动转换
}
Complex c1 = c2 + 10.12;//编译出错,无法自动转换
类型转换函数:
使用转换构造函数可以将指定的数据类型转换为类对象,例如double -> Complex
但是如何将类对象转换为普通的数据类型?需要用类型转换函数
格式为
operator TYPE()
{
xxx
}
e.g.
class Complex
{
operator double() //没有参数类型,没有返回类型,只有一个Type
{
return real;
}
}
double d = c1 + 10.1; //c1会从Complex 转换为double
inline和define的区别是?
define: 预编译时进行处理,只进行简单的替换,不能进行参数有效性检测及使用C++类的成员访问控制。
inline:内联函数对编译器提出建议,是否进行宏替换,编译器有权拒绝。它是个真正的函数,调用时有严格的参数检测;它也可作为类的成员函数。
区别
- 内联函数在编译时展开,而宏是由预处理器对宏进行展开
- 内联函数会检查参数类型,宏定义不检查函数参数 ,所以内联函数更安全。
- 宏不是函数,而inline函数是函数
- 宏在定义时要小心处理宏参数,(一般情况是把参数用括弧括起来)。
struct和class的区别
class默认成员是private,struct默认成员是public
class的默认继承是private,struct的默认继承是public(struct也可以继承)
class可以使用模板,struct不可以
sizeof 和 strlen的区别
strlen是函数,sizeof是运算符
strlen测量字符实际长度,以\0结束
sizeof测量的是字符的分配大小
char a[30] = "abcd";
sizeof(a) = 30, strlen(a) = 4
strlen求字符串长度时,如果字符串只定义没有赋值/赋值中没有\0,结果都是不确定的。
strlen在运行期才可以计算出来,sizeof是在编译器指定的。
所以sizeof无法计算动态分配空间的大小
char* s = (char*)malloc(20);
sizeof(s) = 4;
编译的四个过程
预处理:引入头文件,宏替换,删除注释等。生成.i预编译文件
编译阶段:检查语义语法错误,转换成.s汇编代码
汇编阶段:将汇编代码解释为二进制的CPU指令,生成以.o为结尾的可重定向目标文件
链接阶段:将多个目标文件及所需要的库连接成最终的可执行目标文件
什么是内存泄漏,如何防止内存泄漏,如何判断内存泄漏
内存泄漏是指由于疏忽或错误造成了程序未能释放掉不再使用的内存
- 养成良好的编码规范,申请的空间记得去释放内存。
- 使用内存泄露检测工具进行检查,如Valgrind
- 使用RAII思想或者智能指针来管理资源。
什么是RAII
RAII即“资源获取即初始化”
在构造函数中申请分配资源,在构造函数中申请释放资源。
什么是野指针
野指针就是指向了一个已经删除的对象,或者指向一个未申请且访问受限内存区域的指针。
- 定义指针时未初始化
- 释放指针后未置指针为nullptr
- 指针的操作超过了变量作用域
处理方式:初始化指针时置指针为NULL,释放指针后将指针置为NULL
NULL和nullptr的区别
NULL是用宏实现的,替换为0
nullptr代表空指针,是一个关键字
析构函数必须是虚函数,why,C++默认析构函数不是虚函数,why
如果用父类指针指向一个子类对象,当释放父类指针时也会释放子类空间,防止内存泄漏。
C++默认析构函数不是虚函数,是因为虚函数需要额外的虚表指针,占空间,只有会被继承的类才需要声明虚析构函数。
include头文件””和<>区别
对于双引号包含的头文件,查找头文件默认为当前头文件目录下查找。而尖括号查找头文件默认是编译器设置的头文件路径下查找
什么时候会发生段错误
非法访问内存地址。例如使用野指针,试图修改字符串常量的内容。
变量的声明和定义有什么区别
变量的声明不会分配地址,变量定义时才会分配地址。
一个变量可以在多个地方声明,但是只能在一个地方定义
extern可以声明一个变量,表示该变量在其他地方已经声明好了。
类继承
class Parnet {
public:
virtual void print() {cout<<"Parent的Print被调用了"<<endl;}
};
class Son : public Parent {
public:
void print() override {cout<<"Son的Print被调用了"<<endl;}
void sonson();
};
int main() {
Parent* p = new Son(); //可以,父类的指针可以接受一个子类的对象,此时这个指针被转换为子类指针
p->print(); //输入"Son的Print被调用了"
Son* p = new Parent(); //不可以,子类指针不能接受父类对象
Parent& pp = *s; //同理,父类的引用可以引用子类
dynamic_cast<Son&>(pp).sonson(); //将父类引用动态转为子类引用,从而调用子类引用的方法
}
菱形继承
多继承可能会出现菱形继承的情况,这样子类就存在两个重复的父类数据
解决方法是虚继承
一个类如何访问另一个类的private函数
设置为友元
class Test2;
class Test {
public:
friend class Test2;
};
class Test2 {
};
malloc的底层实现原理是什么
当开辟的空间小于128k时,调用系统调用brk()函数,其主要移动地址空间堆段的末尾地址。
当开辟的空间大于128k时,调用系统调用mmap()函数,用于在虚拟地址空间(堆和栈中间称为文件映射区域)的地方找一块空间来开辟。
STL中的特化和偏特化
Linux如何生成一个动态库?
gcc -fPIC -shared -o libmax.so max.c
内存溢出和内存泄露有什么区别
内存溢出就是指申请时申请的空间不够,比如申请了int型的内存空间,但是存进去一个long long型的数据
内存泄露是指申请内存空间后没有释放掉申请的空间
内存溢出的原因
(1)内存中加载的数据量过于庞大,比如一次性从数据库中读取大量数据。
(2)代码中出现死循环或者循环产生过多重复的实体。
(3)启动参数内存值设定的过小。
(4)使用的第三方软件的bug。
内存泄漏的原因:
(1)new创建出来的对象没有及时的delete掉,导致了内存的泄露;
(2)delete一个void的指针可能会造成内存上的泄露!因为delete一个void的对象指针,它不会调用析构函数,如果该对象里面有指针,最后指针就会没有释放导致内存泄漏。(void为内置类型,不会调用析构函数)
(3)new创建了一组对象数组,内存回收的时候却只调用了delete而非delete []来处理,导致只有对象数组的第一个对象的析构函数得到执行并回收了内存占用,数组的其他对象所占内存得不到回收,导致内存泄露;
class类的内存是怎样分布的
- 如果实例化一个空类,内存中只占用一个字节,作为标识。
- 包含成员变量,不包含成员函数时,根据内存对齐规则,将各个成员变量分布进内存
- 就算包括成员函数,成员函数是不占用类的内存空间的,所以此时类的内存空间仍然和只包含成员变量的时候一样。而成员函数因为是公共的,一个类只有一份,一般根据编译器的不同保存在代码区或者只读区。
- 如果有虚函数时,前四个字节会有一个虚函数表指针只想虚函数表。注意子类的虚函数表会先拷贝父类的表,然后替换和父类中一样的函数,最后补上子类自身的函数。
多个子类,每个子类实例化都有一张虚函数表么
是的,多态中每个类都有一张虚函数表 。虚函数表编译时生成,一般放在二进制文件的.rdata Section, 还有说存放在全局数据区
当存在继承关系的时候,内存分布又是怎样的?
父类和子类都有各自的内存空间。父类的内存空间就是它成员变量根据字节对齐占用的空间。而子类的空间注意了,首先要继承父类的空间,其次自己定义的变量放在父类空间的下面,如果子类定义了和父类同名的变量,调用的时候该变量会覆盖掉父类的变量,但是该变量的位置仍然是在父类空间的下面。另外通过添加类名的方式也可以调用权限为public的父类变量。
你说成员函数不占类的内存空间,那具体是怎样访问成员函数的?类中的函数是如何调用的?用指针是如何偏移的?
一般默认是内联函数,调用该函数的时候就会直接调用。如果涉及到递归之类的,就会像普通函数一样保存现场,然后跳转调用函数。
根据this指针访问成员函数,该指针会指向一个table表,表中保存着各个成员函数的地址。(这个应该是错误的,因为this指针一开始是指向实例化对象的内存起始地址,主要是用来作为参数传入函数内部进行成员变量的调用的)
具体的怎样调用,根据stack overflow上一些大神说,就是和普通函数调用是一样的。但是根据下面第一个链接的文章又说成员函数的调用是通过指针在一个table表里面调用的,所以有点懵。(这个有比较清楚的大佬希望可以指点一下)
this指针存放在哪?
当该非静态成员函数的参数个数一定时,它保存在CPU的EXC寄存器中,未定时(存在可变参数),保存在栈中。普通成员函数的函数地址在代码区。
大端小端
这里的大端小端,说的是从存储方面来看,按顺序低地址一般存储的高位(大端)还是低位(小端)。
大端字节序:就是说存储的时候,高位字节放在低地址,低位字节放在高地址。
小端字节序:存储的时候,刚好相反,高位字节放在高地址,低位字节放在低地址。
计算机内部一般是小端字节序,计算机从低地址到高地址读取,把低位字节放在前面,先处理低位字节效率更高。
大端的好处是TCP/IP协议中的字节序是按照大端规定的,x86的cpu处理所有网络数据包时都要先将数字进行字节序反转才能进行运算,发送时也要先反转才能发送,增加了一点计算开销。
如何判断是大端还是小端
定义变量int i=1;将 i 的地址拿到,强转成char*型,这时候就取到了 i 的低地址,这时候如果是1就是小端存储,如果是0就是大端存储
void judge_bigend_littleend2()
{
int i = 1;
char c = (*(char*)&i);
if (c)
printf("小端\\n");
else
printf("大端\\n");
}
std::string的缺陷
- 内存开销:std::string内部维护了一个动态分配的字符数组,每次字符串的操作(如添加字符和删除字符)都会导致重新分配内存,并复制原字符串到新内存中,增加了额外的内存开销和复杂度。
- 扩容问题:当字符串需要扩容时,std::string一般会分配原字符串长度两倍的空间,而不是仅分配增量空间,这可能会导致内存浪费和更频繁的内存分配与释放,进而影响程序性能。
- 操作效率低:由于std::string的内存管理和操作都是动态的,复杂度较高,导致效率较低,尤其是对于大量字符串的频繁操作,效率更容易退化。
- 不支持多字节字符集:在处理多字节字符集的情况下,std::string可能会导致字符截断和不可预测的错误,降低程序的鲁棒性。
虽然std::string有上述缺点,但我们仍然可以通过一些技巧和优化手段来减少内存开销,提升效率,如避免频繁字符串操作、使用reserve预分配内存等等。而且C++17引入了新的字符串数据结构std::string_view,可以代替部分std::string的应用场景,更加高效地处理字符串操作。
std::string_view和std::string是C++17中两种不同的字符串类型,主要的区别在于:
- 核心应用:std::string是一个动态分配的字符串类型,而std::string_view则是一个不可变的、非拥有的字符串视图类型。前者适用于需要灵活增删改查的字符串操作,后者则适用于只需要查找和遍历操作的场景。
- 数据结构:std::string内部维护了一个动态分配的字符数组,而std::string_view只是一个对字符串的字符范围的描述,因此不需要分配内存,具有更小的内存占用。
- 性能表现:由于std::string的操作需要进行内存分配和拷贝等操作,一般会比std::string_view的操作更加耗时,尤其是在频繁进行较小规模的字符串处理时效果更为明显。
- 可读性:std::string_view可使得代码更加清晰明了,因为它只负责提供字符串的读取和操作视图,而std::string则包含了更多的底层细节操作。
综上,当我们需要进行大量的赋值、添加或删除操作时,建议使用std::string;当我们只需要遍历、截取或查找等操作时,使用std::string_view能够更快地完成任务,并且不会造成额外的内存开销
原生指针和智能指针的开销一样吗
unique_ptr 在默认情况下和裸指针的大小是一样的
shared_ptr 是原生指针的2倍,因为shared_ptr对象中有引用计数
浮点数存储方式
IEEE 754标准规定了浮点数的存储格式,它包括32位单精度浮点数和64位双精度浮点数两种。其中,单精度浮点数用32位二进制表示,包括1位符号位、8位阶码和23位尾数;双精度浮点数用64位二进制表示,包括1位符号位、11位阶码和52位尾数。符号位表示正负,阶码表示指数,尾数表示浮点数的小数部分。
在IEEE 754标准中,浮点数的存储是采用尾数规格化的方式,即将浮点数表示为一个小数和指数(或幂)的积的形式。具体采用科学计数法的形式表示,比如-0.75可以表示为-7.5 x 10^-1。浮点数的精度是由尾数的二进制位数决定的,32位单精度浮点数的有效数字为7位,双精度浮点数的有效数字为16位。
MD5, Base64
MD5和Base64都是常用于数据加密和编码的算法。
- MD5:
MD5(Message Digest Algorithm 5)是一种消息摘要算法,它能对任意长度的数据生成一个128位(即16字节)的哈希值,然后将哈希值转化为16进制字符串。MD5广泛应用于文件校验和、数据完整性验证、数字签名等场景,其加密过程不可逆,即无法通过哈希值推算出原始数据。
MD5的主要特点包括:安全性较高、哈希值固定长度、计算速度较快、适用于数据较小的场景。
- Base64:
Base64是一种编码方式,它将二进制数据转化为64个字符之内的文本字符,用于在网络中传输数据。在Base64编码中,每3个字节(即24位)数据被编码为4个字符,因此Base64编码后的文本字符长度通常比原始数据长度要长。
Base64的特点包括:可以将二进制数据安全地传输为文本形式,减少数据传输量,但编码后数据泄漏也能被恢复;编码和解码计算速度快,但是加密和解密的过程都是可逆的。
在实际应用中,MD5和Base64常常结合使用。比如,对于敏感信息,可以使用MD5进行加密生成哈希值,然后将哈希值通过Base64编码的方式传输,提高信息的安全性并减小数据传输量。
二进制数据→Base64 可以减少数据传输量
utf-8和utf-16
ASCII是一种字符编码标准,定义了128个字符(包括英文字母、数字和特殊字符)和它们的编号。ASCII编码只使用一个字节(8位)来表示每一个字符。
UTF-8和UTF-16都是Unicode的编码方式,可以表示全世界范围内的字符,包括汉字、日语假名、希腊字母等等。UTF-8使用变长编码方式,从1到4个字节不等,它能够向后兼容ASCII编码方式;UTF-16则是采用定长编码方式,每个字符使用2个字节来表示。
所以,ASCII只能表示128个字符,而UTF-8和UTF-16能够覆盖全世界的字符,而它们之间的不同在于编码方式和字符表示的字节数。
虚函数总结
普通类生成对象时,这个对象是不保留成员函数的空间的
例如
class Base {
void print() {}
int a;
}
class BaseWithVptr {
virtual void print() {}
int a;
}
class Derive : public BaseWithVptr {
int b;
}
class DeriveOverride : public BaseWithVptr {
void print() override {}
}
int main() {
Base b;//这里的对象b只有a这个成员变量,没有print
BaseWithVptr bv;//这里的对象bv有a这个成员变量,还有_vptr这个虚函数指针,指向虚函数表
Derive d;//d也有虚函数指针,由于Derive没有重写父类的虚函数,所以指针指向父类的虚函数表
DeriveOverride do; //do有虚函数指针,但是虚函数指针指向的自己的虚函数表
}
对象首地址开始的4个字或是8个字节,放虚函数指针,它包含一个地址指向虚函数表
虚函数表本质上是函数指针数组,它所在的位置就是虚函数表指针里面存储的地址,里面包含的地址就是重写了父类的虚函数地址。
虚表指针在对象的构造函数调用时被初始化
拥有虚函数的类会有一个虚表,而且这个虚表存放在类定义模块的数据段中。模块的数据段通常存放定义在该模块的全局数据和静态数据,这样我们可以把虚表看作是模块的全局数据或者静态数据。
类的虚表会被这个类的所有对象所共享。类的对象可以有很多,但是他们的虚表指针都指向同一个虚表,从这个意义说,我们可以把虚表简单理解为类的静态数据成员。值得注意的是,虽然虚表是共享的,但是虚表指针并不是,类的每一个对象有一个属于它自己的虚表指针。
虚函数表在编译时候就已经确定
子类是怎么找到对应虚函数的入口指针的,为什么不会调用到父类的虚表?
一个类对象的指针赋值成nullptr能调用成员函数吗?虚函数呢?为什么?
这些都是未定义行为,能否正确运行取决于成员函数用没用到this指针。由于类的非静态成员函数隐含一个this指针,类的成员函数是独立于对象存在的。当对象调用成员函数时,会把this指针传到class内的成员函数,当对象指针指向nullptr后,成员函数的this实参为nullptr,如果没有用到this指针,例如成员函数里只输出了什么东西,那么就没问题。如果用到了this指针,例如访问了成员变量,或者虚表指针,就会出错。
Union大小计算
联合体所占的空间不仅取决于最宽成员,还跟所有成员有关系,即其大小必须满足两个条件:1)大小足够容纳最宽的成员;2)大小能被其包含的所有基本数据类型的大小所整除
C++如何创建一个类,使得他只能在堆或者栈上创建?
只能在堆上:将析构函数设置为私有
只能在堆上生成对象:将析构函数设置为私有。
原因:C++是静态绑定语言,编译器管理栈上对象的生命周期,编译器在为类对象分配栈空间时,会先检查类的析构函数的访问性。若析构函数不可访问,则不能在栈上创建对象。
只能在栈上生成对象:将new 和 delete 重载为私有。
原因:在堆上生成对象,使用new关键词操作,其过程分为两阶段:第一阶段,使用new在堆上寻找可用内存,分配给对象;第二阶段,调用构造函数生成对象。将new操作设置为私有,那么第一阶段就无法完成,就不能够再堆上生成对象。
构造函数/析构函数中可以调用虚函数吗
不能。这个问题来自于《Effective C++》条款9:永远不要在构造函数或析构函数中调用虚函数 。
简要结论: 1. 从语法上讲,调用完全没有问题。 2. 但是从效果上看,往往不能达到需要的目的。 Effective 的解释是: 派生类对象构造期间进入基类的构造函数时,对象类型变成了基类类型,而不是派生类类型。 同样,进入基类析构函数时,对象也是基类类型。
完美转发是干啥的
std::forward()
左值转发成左值,右值转发成右值
堆和栈的区别
管理方式不同。栈由操作系统自动分配释放,无需我们手动控制;堆的申请和释放工作由程序员控制,容易产生内存泄漏;
生长方向不同。堆的生长方向向上,内存地址由低到高;栈的生长方向向下,内存地址由高到低。
c++静态链接和动态链接区别
静态链接和动态链接都是将函数库中的代码与主程序的代码合并,它们的主要区别在于链接时机和链接方式。
静态链接指的是在编译时将函数库中的代码拷贝到主程序中,并生成一个完整的可执行程序。这种方式会使得可执行文件变得比较大,但是运行时不需要借助外部函数库,因此具有独立性和稳定性。但是修改库代码都需要重新编译整个程序,开发和部署都比较困难。
动态链接指的是在运行时需要特定的函数库时,需要从系统中加载函数库代码。这种方式使得可执行文件较小,启动速度相对较快,同时也方便了开发和部署。但是程序的稳定性会受到外部函数库代码的影响,同时如果不同版本的函数库存在冲突,也有可能导致程序运行失败。
另外,动态链接对于多个程序共享同一个函数库可以节省系统资源,因为同一个函数库只需要被加载一次。而静态链接要求每个程序都要将函数库代码复制到其可执行文件中,会浪费系统资源。
综上所述,静态链接和动态链接各有优缺点,一般情况下应该根据实际情况进行选择和使用。
头文件里可以放什么
在头文件(Header File)中可以放置以下内容:
1.函数和变量的声明:在头文件中可以声明函数和变量的原型,这样在其他文件中可以调用这些函数和访问这些变量。
2.宏定义:在头文件中可以定义宏,这样其他的源文件就可以使用这些宏进行编程。
3.结构体和枚举类型的定义:在头文件中可以定义结构体和枚举类型,这些类型在程序中可能需要多处使用,因此将它们定义在头文件中可以方便其他文件共享。
4.引用其他头文件:在头文件中可以引用其他需要的头些头文件中可能包含了一些程序所需要的全局变量、函数声明等内容。
总之,头文件主要是为了使程序在各个模块之间共享代码,避免让每个文件都重复写相同的原型、宏、结构体和枚举类型等内容。而且,正确和合理地使用头文件也有助于提高代码的可读性,降低编码难度,提高代码的可维护性。
构造函数抛出异常
在C++中,构造函数也可以抛出异常。当构造函数抛出异常时,会从构造函数中抛出异常,然后回溯到创建对象的地方,清理构造函数中已经分配的资源,然后再次抛出异常。
如果在构造函数中发生异常而未被捕获,对象就不会被创建。如果对象在堆上分配内存,则动态内存仍需要使用delete运算符显式删除, 否则会导致内存泄漏。
当然,最好的做法是在构造函数中避免抛出异常,因为它通常会使代码变得更加复杂。如果必须抛出异常,则应该在构造函数中进行必要的清理工作,以及确保没有内存泄漏。
需要注意的是,如果类中的某个成员变量的构造函数抛出异常,则尚未构造完成的成员变量需要被正确释放。这可以通过使用异常规范和try-catch块来实现。在构造函数中的try块中构造对象,在其中使用catch块中的代码清理任何未成功构造的对象。
c++中i++ 和++i的区别?++i快还是i++更快呢?两个float类型如何判断相等?
i++是后缀自增运算符,它返回i的原值,然后将i自增1;而++i是前缀自增运算符,它在将i自增1之前返回i的新值。
在实际应用中,++i和i++的效率是相同的。但某特定的场景下,++i可能会比i++稍微快一些。这是因为,i++需要在内存中保存i的原值,而++i不需要保存。
可以用两个float作差在某个区间内比较