【嵌入式八股精华版】一、语言篇
完整版见以下专栏:
【嵌入式八股】一、语言篇https://www.nowcoder.com/creation/manager/columnDetail/mwQPeM
【嵌入式八股】二、计算机基础篇https://www.nowcoder.com/creation/manager/columnDetail/Mg5Lym
【嵌入式八股】三、硬件篇https://www.nowcoder.com/creation/manager/columnDetail/MRVDlM
【嵌入式八股】四、嵌入式Linux篇https://www.nowcoder.com/creation/manager/columnDetail/MQ2bb0
一、语言篇
01.基础
01.关键字static的作用
一般来说
1.static的第一个作用是隐藏。(static函数,static变量均可)
当同时编译多个文件时,所有未加static前缀的全局变量和函数都具有全局可见性。
2.static的第二个作用是变量只初始化一次,保持变量内容的持久。(static变量中的记忆功能和全局生存期)
存储在静态数据区的变量会在程序刚开始运行时就完成初始化,也是唯一的一次初始化。共有两种变量存储在静态存储区:全局变量和static变量,只不过和全局变量比起来,static可以控制变量的可见范围,说到底static还是用来隐藏的。
3.static的第三个作用是默认初始化为0(static变量)
其实全局变量也具备这一属性,因为全局变量也存储在静态数据区。在静态数据区,内存中所有的字节默认值都是0x00,某些时候这一特点可以减少程序员的工作量。
static的第一个作用具体来说
-
在函数体内static变量的作用范围为该函数体,不同于auto变量,该变量的内存只被分配一次,在函数体,只会被初始化一次,因此其值在下次调用时仍维持上次的值;
-
在模块内的static全局变量可以被模块内所有函数访问,但不能被模块外其它函数访问(只能被当前文件使用);
-
在模块内的static函数只可被这一模块内的其它函数调用,这个函数的使用范围被限制在声明它的模块内(只能被当前文件使用);
-
在类中的static成员变量
属于整个类所拥有,只与类关联,不与类的对象关联;
定义时要分配空间,不能在类声明中初始化,必须在类定义体外部初始化,初始化时不需要标示为static;
可以被非static成员函数任意访问。
-
在类中的static成员函数
属于整个类所拥有,不具有this指针;
无法访问类对象的非static成员变量和非static成员函数;
不能被声明为const、虚函数和volatile;
可以被非static成员函数任意访问。
类内static相关说明:
类中的static成员变量
在类内的数据成员声明前加上关键字static,则该成员将会被声明为静态数据成员.
#include <bits/stdc++.h>
using namespace std;
class TempClass
{
public:
TempClass(int a, int b, int c);
void Show();
private:
int a,b,c;
static int T;
};
int TempClass::T = 0;//初始化静态数据成员
TempClass::TempClass(int a, int b, int c)
{
this->a = a;
this->b = b;
this->c = c;
T = a + b + c;
}
void TempClass::Show()
{
printf("T is %d\n", T);
}
int main()
{
TempClass ClassA(1,1,1);
ClassA.Show();//输出1+1+1 = 3;
TempClass ClassB(3,3,3);
ClassB.Show();//输出3+3+3 = 9;
ClassA.Show();//输出9
return 0;
}
从上面的测试代码可以看出静态数据成员的特点:
- 静态数据成员的服务对象并非是单个类实例化的对象,而是所有类实例化的对象(这点可以用于设计模式中的单例模式实现).
- 静态数据成员必须显示的初始化分配内存,在其包含类没有任何实例化之前,其已经有内存分配.或者说这个变量只属于这个类,不属于任何类实例化的对象,不能用某个对象去赋值,所以static修饰的变量要在类外初始化
- 静态数据成员与其他成员一样,遵从public,protected,private的访问规则.
- 静态数据成员内存存储在全局数据区,只随着进程的消亡而消亡.
静态数据成员与全局变量相比的优势:
- 静态数据成员不进入程序全局名字空间,不会与其他全局名称的同名同类型变量冲突.
- 静态数据成员可以实现C++的封装特性,由于其遵守类的访问权限规则.所以相比全局变量更加灵活.
类中的static成员函数
在类的成员函数返回类型之前添加static,即可声明此成员函数为静态成员函数.
#include <stdio.h>
class TempClass
{
public:
TempClass(int a, int b, int c);
static void Show();
private:
int a,b,c;
static int T;
};
int TempClass::T = 0; //初始化静态数据成员
TempClass::TempClass(int a, int b, int c)
{
this->a = a;
this->b = b;
this->c = c;
T = a + b + c;
}
void TempClass::Show()
{
printf("T is %d\n", T);
}
int main()
{
TempClass ClassA(1,1,1);
ClassA.Show();
TempClass ClassB(3,3,3);
ClassB.Show();
TempClass::Show(); //注意此处的调用方式.
return 0;
}
从上面的示例代码中可以看出静态成员函数的特点如下:
- 静态成员函数比普通成员函数多了一种调用方式.
- 静态成员函数为整个类服务,而不是具体的一个类的实例服务.
- 由于static修饰的类成员属于类,不属于对象,因此static类成员函数是没有this指针的,this指针是指向本对象的指针。正因为没有this指针,所以static类成员函数不能访问非static的类成员,只能访问 static修饰的类成员;
关于this指针的深入解释
在C++中,this指针是一个隐含的指针,它指向当前对象的地址。当一个成员函数被调用时,编译器会将该函数的调用对象的地址作为this指针传递给函数。例如调用函数Fun(),实际上是this->Fun().静态成员函数中没有这样的this指针,所以静态成员函数不能操作类中的非静态成员函数.否则编译器会报错.
类内的静态成员函数不能被声明为 const
、虚函数或 volatile
的原因如下:
const
const
修饰的成员函数是指其在函数内部不会修改对象的状态。这意味着该成员函数只能访问对象的 const
成员,而不能访问非 const
成员。由于静态成员函数不依赖于任何对象,因此它们不能声明为 const
。在 C++ 中,编译器会在静态成员函数上使用 const
修饰符时给出编译错误。
虚函数
虚函数是指在基类中声明的函数,在派生类中可以被重写。static成员不属于任何对象或实例,所以加上virtual没有任何实际意义;静态成员函数没有this指针,虚函数的实现是为每一个对象分配一个vptr指针,而vptr是通过this指针调用的,所以不能为virtual;虚函数的调用关系,this->vptr->ctable->virtual function。
volatile
volatile
修饰的变量是指该变量可能在任何时候都会被修改,因此编译器不会将该变量缓存在寄存器中,而是每次都从内存中读取该变量的值。然而,volatile
不适用于类的静态成员函数,因为静态成员函数没有隐含的 this 指针,也就是说它没有与特定对象实例相关联。而 volatile
关键字的主要目的是告诉编译器不要对变量进行优化,确保每次访问都是从内存中读取最新值,但对于静态成员函数来说,并不存在与对象实例相关的内存位置。静态成员函数不依赖于任何对象,只能对静态成员变量做修改,不涉及任何其他对象变量的修改,因此不能声明为 volatile
。在 C++ 中,编译器会在静态成员函数上使用 volatile
修饰符时给出编译错误。
在类的静态成员函数中,无法使用 volatile
来强制编译器在每次调用时都重新读取某个特定对象实例的数据,因为静态成员函数是针对类本身而不是类的实例的。如果需要使用 volatile
行为来处理类的静态数据,你可以在静态数据成员上使用 volatile
关键字,而不是将其应用于整个静态成员函数。
static的第二个作用是变量只初始化一次,保持变量内容的持久
示例
#include <stdio.h>
void func() {
static int staticVar=0; // 静态变量,只初始化一次。
printf("Static variable: %d\n", staticVar);
staticVar++; // 每次调用函数,静态变量增加1
}
int main() {
func(); // 第一次调用
func(); // 第二次调用
func(); // 第三次调用
return 0;
}
输出
Static variable: 0
Static variable: 1
Static variable: 2
02.extern作用
可以在一个文件中引用另一个文件中定义的变量或者函数
extern关键字只需要指明类型和变量名就行了,不能再重新赋值
-
引用同一个文件中的变量利用extern关键字,使用在后边定义的变量
#include<stdio.h> int func(); int main() { func(); //1 extern int num; printf("%d",num); //2 return 0; } int num = 3; int func() { printf("%d\n",num); }
-
引用另一个文件中的变量 使用include将另一个文件全部包含进去可以引用另一个文件中的变量,但是这样做的结果就是,被包含的文件中的所有的变量和方法都可以被这个文件使用,这样就变得不安全,如果只是希望一个文件使用另一个文件中的某个变量还是使用extern关键字更好。
main.c #include<stdio.h> int main() { extern int num; printf("%d",num); return 0; }
b.c #include<stdio.h> intnum = 5; voidfunc() { printf("fun in a.c"); }
-
引用另一个文件中的函数
main.c #include<stdio.h> int main() { extern void func(); func(); return 0; }
b.c #include<stdio.h> const int num=5; void func() { printf("fun in a.c"); }
03.extern ”C” 的作用
在 C++ 中,extern "C" 用于指定某个函数、变量、代码块等按照 C 语言的规则进行编译和链接,以便与 C 语言代码进行互操作。
当使用 C++ 编译器编译 C++ 代码时,编译器会将函数名进行名称修饰以支持函数重载等特性。而 C 语言并不支持函数重载,函数名也不进行名称修饰。因此,当我们在 C++ 代码中调用 C 语言中的函数时,需要使用 extern "C" 来告诉编译器不要对这个函数名进行名称修饰,以便与 C 语言代码进行互操作。
另外,extern "C" 也可以用于解决 C++ 代码在链接时找不到符号的问题。在 C++ 中,如果我们定义了一个函数或变量但没有给它赋初值,那么编译器会将这个函数或变量放在未初始化数据段(BSS)中,而不是在已初始化数据段(DATA)中。然而,在链接时,如果其它模块中没有找到这个符号,链接器会报错。这时,我们可以使用 extern "C" 来告诉编译器不要对这个符号进行名称修饰,以便在链接时能够找到对应的符号。
示例
- C++调用C函数:
//xx.h
extern int add(...)
//xx.c
int add(){
}
//xx.cpp
extern "C" {
#include "xx.h"
}
- C调用C++函数
//xx.h
extern "C"{
int add();
}
//xx.cpp
int add(){
}
//xx.c
extern int add();
04.什么情况下使用const关键字?
**1)修饰一般常量。**在定义时必须初始化,之后无法更改
2)修饰常数组。
3)修饰常指针。
const int *a;
int const *a; // a是一个指向整型常量的指针变量,指针所指向的内容只读
int * const a; // a是一个指向整型变量的指针常量,指针本身是只读的
const int * const a = &b;
int const * const a = &b; // a是一个指向整型常量的指针常量,指针所指向的内容只读且指针本身是只读的
4)修饰函数的常参数。
- 如果函数参数采用“指针传递”,那么加 const 修饰可以防止意外地改动该指针指向的内容,起到保护作用。
char *strcpy(char *strDest, const char *strSrc); // 参数在函数内部不会被修改
- const 用于修饰“指针传递”的参数,以防意外改动指针本身,C++引用的原型。
void swap ( int * const p1 , int * const p2 );
- 如果输入参数采用“值传递”,由于函数将自动产生临时变量用于复制该参数,该输入参数本来就无需保护,所以不要加 const 修饰。
例如不要将函数 void Func1(int x) 写成 void Func1(const int x);
5)修饰函数的返回值。
- 如果给用 const修饰返回值的类型为指针,那么函数返回值(即指针)的内容是不能被修改的, 而且这个返回值只能赋给被 const修饰的指针。
const char *GetString() //定义一个函数
char *str= GetString() //错误,因为str没有被const修饰
const char *str=GetString() //正确
- 如果用 const修饰普通的返回值,如返回int变量,由于这个返回值是一个临时变量,在函数调用结束后这个临时变量的生命周期也就结束了,因此把这些返回值修饰为 const是没有意义的。
int GetInt(void);
const int GetInt(void);
6)修饰常引用。
- 引用变量
变量初始化,再const引用变量
int b = 10;
const int &a = b;
b = 11;//b是可以修改的,但是a不能修改
- 引用常量
const引用常量
const int &c = 15;
//编译器会给常量15开辟一片内存,并将引用名作为这片内存的别名
//int &d=15//err
- 用于函数的形参。常引用做形参,可以确保在函数内不会改变实参的值,所以参数传递时尽量使用常引用类型。
7)修饰类的成员变量
不能在类定义外部初始化,只能通过构造函数初始化列表进行初始化,并且必须有构造函数;不同类对其const数据成员的值可以不同,所以不能在类中声明时初始化。
8)修饰类的成员函数
const对象不可以调用非const成员函数;非const对象都可以调用;
不可以改变非mutable(用该关键字声明的变量可以在const成员函数中被修改)数据的值。
class People
{
public:
int talk(void);
int eat(void) const; // const 成员函数
private:
int m_age;
};
int People::eat(void) const
{
++m_age; // 编译错误,企图修改数据成员m_num
talk(); // 编译错误,企图调用非const函数
return m_age;
}
9)修饰常对象。定义常对象时,同样要进行初始化,并且该对象不能再被更新,修饰符const可以放在类名后面,也可以放在类名前面。一旦将对象定义为常对象之后,不管是哪种形式,该对象就只能访问被 const 修饰的成员了(包括 const 成员变量和 const 成员函数),因为非 const 成员可能会修改对象的数据(编译器也会这样假设),C++禁止这样做。
// show是普通成员函数,get是const成员函数
int main(){
const Student stu("小明", 15, 90.6);
//stu.show(); //error
cout<<stu.getname()<<"的年龄是"<<stu.getage()<<",成绩是"<<stu.getscore()<<endl;
const Student *pstu = new Student("李磊", 16, 80.5);
//pstu -> show(); //error
cout<<pstu->getname()<<"的年龄是"<<pstu->getage()<<",成绩是"<<pstu->getscore()<<endl;
return 0;
}
本例中,stu、pstu 分别是常对象以及常对象指针,它们都只能调用 const 成员函数
05.const 与define区别
定义常量谁更好?# define还是 const?
define与 const都能定义常量,效果虽然一样,但是各有侧重。 define既可以替代常数值,又可以替代表达式,甚至是代码段,但是容易出错,而const的引入可以增强程序的可读性,它使程序的维护与调试变得更加方便。具体而言,它们的差异主要表现在以下几个方面。
-
编译器处理方式不同
define宏是预编译指令,在预处理阶段展开。
const常量是普通变量的定义,编译运行阶段使用。
-
存储方式不同
define宏仅仅是展开,有多少地方使用,就展开多少次,不会分配内存。(宏定义不分配内存,变量定义分配内存。)
const常量会在内存中分配(可以是堆中也可以是栈中),const 可以节省空间,避免不必要的内存分配。
const定义的是变量,而define定义的是常量。define定义的宏在编译后就不存在了,它不占用内存,因为它不是变量,系统只会给变量分配内存。但const定义的常变量本质上仍然是一个变量,具有变量的基本属性,有类型、占用存储单元。可以说,常变量是有名字的不变量,而常量是没有名字的。有名字就便于在程序中被引用,所以从使用的角度看,除了不能作为数组的长度,用const定义的常变量具有宏的优点,而且使用更方便。所以编程时在使用const和define都可以的情况下尽量使用常变量来取代宏。
-
类型和安全检查不同
define宏没有类型,不做任何类型检查,仅仅是展开。容易出问题,即“边际问题”或者说是“括号问题”。
const常量有具体的类型,在编译阶段会执行类型检查。
-
const可以调试
const 只读变量是可以进行调试的,define 是不能进行调试的,因为在预编译阶段就已经替换掉了。
一般问什么和什么的区别,可以从存储方式、编译阶段、类型检查、可否调试、应用对象、作用域这几方面来考虑说明就行。
记忆:存编型,调对域
06.volatile作用
声明变量是易变,避免被编译器优化
声明volatile后 //编译器就不会优化,会从内存重新装载内容,而不是直接从寄存器拷贝内容(副本) //否则会优化,会读寄存器里的副本,而重新读内存(因寄存器比内存快)
07.volatile用法
**读硬件寄存器时(如某传感器的端口/裸机程序编写时)**并行设备的硬件寄存器。存储器映射的硬件寄存器通常加volatile,因为寄存器随时可以被外设硬件修改。当声明指向设备寄存器的指针时一定要用volatile,告诉编译器不要对存储在这个地址的数据进行假设。
//假设某烟雾传感器的 硬件寄存器如下(当又烟雾时报警变为1)
#define GPA1DAT (*(volatile unsigned int*)0xE0200084)
void main(){
while (1){//反复读取GPA1DAT值,当为1时火灾报警
if (GPA1DAT) { //如不加volatile,编译器优化后,变成只读一次,
//后面用的是副本数据。一直为0
fire()
break;
}
}
}
解释
1.#define GPA1DAT (*(volatile unsigned int*)0xE0200084)
将 GPA1DAT 宏定义为地址 0xE0200084 上的内容
2.(volatile unsigned int*)0xE0200084
将0xE0200084强制转换为地址int型指针(相当于*p中的p,指的是地址)
3.(*(volatile unsigned int*)0xE0200084)
这句代码则代表地址为0xE0200084上的存放内容(相当于*p,指的是地址上的内容)
4.#define GPA1DAT (*(volatile unsigned int*)0xE0200084)
将地址0xE0200084上的内容定义为GPA1DAT,如果操作GPA1DAT = 1;则地址0x40000000上存放的内容就变成了1,也可以读.
//裸机程序编写时
//main.c
#define CNF (*(volatile int*)0x6000D204) //配置寄存器 (0:GPIO 1:SFIO)
#define OE (*(volatile int*)0x6000D214) //输出使能寄存器 (1:使能 0:关闭)
#define OUT (*(volatile int*)0x6000D224) //输出寄存器(1:高电平 0:低电平)
#define MSK_CNF (*(volatile int*)0x6000D284) //配置屏蔽寄存器(高位1:屏蔽 高位0:不屏蔽 低位1:GPIO模式 低位0:SFIO模式)
#define MSK_OE (*(volatile int*)0x6000D294) //输出使能屏蔽寄存器(高位1:禁止写 低位1:使能)
#define MSK_OUT (*(volatile int*)0x6000D2A4) //输出屏蔽寄存器(高位1:禁止写 低位1:高电平)
#define DAP4_SCLK_PJ7 (*(volatile int*)0x70003150)//管脚复用
//开灯
void led_on(void)
{
//管脚复用
DAP4_SCLK_PJ7 = DAP4_SCLK_PJ7&(~(1 << 4)); //【位清零程序写法】
//取消GPIO3_PJ7 引脚的屏蔽
MSK_CNF = (MSK_CNF)|(1<<7); //取消对GPIO模下引脚的屏蔽 //【位置一程序写法】
MSK_OE = (MSK_OE)|(1<<7); //取消引脚 使能屏蔽
//配置GPIO3_PJ7 引脚 输出高电平
CNF = (CNF)|(1<<7); //配置引脚为 GPIO模式
OE = (OE)|(1<<7); //使能引脚
OUT = (OUT)|(1<<7); //引脚输出高电平,点亮灯
}
int main(void)
{
led_on();
while(1)
{
}
return 0;
}
中断中对共享变量的修改一个中断服务程序中修改的供其他程序检测的变量。volatile提醒编译器,它后面所定义的变量随时都有可能改变。因此编译后的程序每次需要存储或读取这个变量的时候,都会直接从变量地址中读取数据。如果没有volatile关键字,则编译器可能优化读取和存储,可能暂时使用寄存器中的值,如果这个变量由别的程序更新了的话,将出现不一致的现象。
static int i=0; //应加volatile修饰
int main(void)
{
...
while (1){
if (i) { //虽中断中更改了i的值,但因未声明i是易变的,
//编译器优化后,导致它读的是副本数据,导致一直循环不退出
break;
}
}
}
void interrupt(void)
{
i=1; //中断中改变 i的值,但
}
多线程中对共享的变量的修改多线程应用中被几个任务共享的变量。单地说就是防止编译器对代码进行优化,比如如下程序:
volatile char bStop = 0; //注意:需声明为volatile,线程而才能通过它停止线程1
//如不声明,编译器优化后,变成一直读副本数据。
void thread1(){
while(!bStop) {
//...一直循环做一些事情
}
}
void thread2(){
//...处理一些事情后。
bStop =1; //终止线程2
}
08.手写函数:strlen, strcpy, strstr, strcat, strcmp
- strlen函数
功能:计算给定字符串的(unsigned int型)长度,不包括'\0'在内
#include <bits/stdc++.h>
using namespace std;
int strlen(const char *str) {
assert(str != NULL); //assert(str);就行
int len = 0;
while( (*str++) != '\0')
len++;
return len;
}
int main(void)
{
const char a[] = "963852";
int ret = mystrlen(a);
cout << ret << endl;
return 0;
}
- strcpy()函数
C语言库函数模拟实现之strcpy_哔哩哔哩_bilibili
功能:字符串复制函数,strcpy把含有'\0'结束符的字符串复制到另一个地址空间,返回值的类型为char*。
#include <bits/stdc++.h>
using namespace std;
char* mystrcpy(char *dest, const char *src) {
assert(dest && src);
char *ret = dest;
while((*dest++ = *src++) != '\0');
return ret;
}
int main(void)
{
const char a[] = "963852";
char b[10] = "";
char* ret = mystrcpy(b,a);
cout << ret << endl;
return 0;
}
注意:在该函数中,ret
指针所指向的内存空间是在函数的栈帧中分配的。栈帧是函数在运行时所占用的内存空间,其中包括函数参数、局部变量、返回地址等。当函数执行完毕后,该栈帧会被弹出,函数的所有局部变量和参数也会被销毁。因此,ret
指针所指向的内存空间在函数执行完毕后就已经被销毁,此时如果再使用该指针访问该内存空间,就会导致未定义行为,可能会出现意想不到的错误。
然而,这里的代码中返回的是 dest
指针,而不是 ret
指针。ret
指针仅仅用于保存 dest
的初始值,而在函数执行过程中并没有改变。因此,返回 ret
指针并不会导致问题。
需要注意的是,如果函数返回的是指向函数内部局部变量的指针,那么在函数执行完毕后,该指针指向的内存空间就已经被销毁,因此使用该指针可能会导致未定义行为。
- strcat()函数
功能:把src所指向的字符串(包括“\0”)复制到dest所指向的字符串后面(删除*dest原来末尾的“\0”)。要保证*dest足够长,以容纳被复制进来的*src。*src中原有的字符不变。返回指向dest的指针。
#include <bits/stdc++.h>
using namespace std;
char* mystrcat(char* dest, const char* src) {
assert(dest && src);
char* ret = dest;
while((*dest++) != '\0'); //当strDest='\0'时结束,即为字符串的结尾,将strSrc添加到此处
dest--;
while((*dest++ = *src++) != '\0'); //将src拷贝到dest
return ret;
}
int main(void)
{
const char a[] = "963852";
char b[20] = "147";
char* ret = mystrcat(b, a);
cout << ret << endl;
return 0;
}
- strcmp()函数
功能:对两个字符串进行比较,若s1、s2字符串相等,则返回零;若s1大于s2,则返回正数;否则,则返回负数。
#include <bits/stdc++.h>
using namespace std;
int strcmp(const char* str1, const char* str2) {
assert(str1 && str2);//assert((str1 != NULL) && (str2 != NULL));
int ret = 0;
//ret=0,相等,相等时要确定两个字符不为'\0'; ret!=0时,循环结束,判断ret值
while( !(ret = *(unsigned char*)str1 - *(unsigned char*)str2) && *str1 ) {
str1++;
str2++;
}
if(ret > 0) return 1;
else if(ret < 0) return -1;
return 0;
}
int main(void)
{
const char a[] = "963852";
char b[20] = "147";
int ret = mystrcmp(b, a);
cout << ret << endl;
return 0;
}
- strstr()函数
功能:strstr(str1,str2) 函数用于判断字符串str2是否是str1的子串。如果是,则该函数返回str2在str1中首次出现的地址;否则,返回NULL。
#include <bits/stdc++.h>
using namespace std;
int strstr(const char *str, const char *substr) {
assert(str && substr);
int lenstr = strlen(str);
int lensub = strlen(substr);
if(lenstr < lensub)
return -1;
int i,j;
for(int i = 0; i <= lenstr-lensub; ++i) {
for(j = 0; j < lensub; ++j) {
if(str[i+j] != substr[i])
break;
}
if(j == lensub)
return i;
}
return -1;
}
int main(void)
{
const char a[] = "963852";
char b[] = "38";
int ret = mystrstr(a, b);
cout << ret << endl;
return 0;
}
09.手写函数:memset,memcpy,memmove,memcmp
- memset
功能:内存的初始化
#include <bits/stdc++.h>
using namespace std;
void* mymemset(void* dest, int c, size_t count)
{
assert(dest);
char* pdest= (char*)dest;
while (count--)
{
*pdest++ = c;
}
return dest;
}
void main()
{ int ar[10] = { 1,2,3,5,6,78,8,9,4 };
mymemset(ar, 0, sizeof(ar));
for (int i = 0; i < 10; ++i)
{
printf("%d", ar[i]);
}
}
- memcpy()函数
功能:将str指向地址为起始地址的连续n个字节的数据复制到以dest指向地址为起始地址的空间内,函数返回一个指向dest的指针.
#include <bits/stdc++.h>
using namespace std;
void* memcpy(void *dest, const void *src, size_t n) {
assert(dest && src);
char* pdest = (char*)dest;
char* psrc = (char*)src;
while(n--)
*pdest++ = *psrc++;
return dest;
}
- memcmp
功能:这个函数用来比较 s1 和 s2 所指的内存区间前 n 个字符。
第一个字符串大于第二个字符串,则返回大于0的数字;
第一个字符串等于第二个字符串,则返回0;
第一个字符串小于第二个字符串,则返回小于0的数字。
#include <bits/stdc++.h>
using namespace std;
int mymemcmp(const void* buf1, const void* buf2, size_t count)
{
assert(buf1 && buf2);
const char* pf1 = (const char*)buf1;
const char* pf2 = (const char*)buf2;
int res = 0;
while (count--)
{
res = *pf1 - *pf2;
if (res != 0)
break;
pf1++;
pf2++;
}
return res;
}
void main()
{
char str1[20] = "hello";
char str2[20] = "helloworld";
int a = mymemcmp(str1, str2, 3);
printf("%d", a);
}
- memmove
memmove函数以及memmove模拟实现_川入的博客-CSDN博客
功能:能够对本身进行覆盖拷贝的函数,其又同时兼备了 memcpy函数可做的事
#include <bits/stdc++.h>
using namespace std;
//memmove函数模拟
void* my_memmove(void* dest, const void* src, int count)
{
assert(dest && src);
void* ret = dest;
if (src > dest)
{
//顺顺序
while (count--)
{
*(char*)dest = *(char*)src;
dest = (char*)dest + 1;
src = (char*)src + 1;
}
}
else
{
//逆顺序
while (count--)
{
*((char*)dest+count) = *((char*)src + count);
}
}
return ret;
}
//数组的打印
void print(int* arr, int sz)
{
for (int i = 0; i < sz; i++)
{
printf("%d ", arr[i]);
}
}
int main()
{
int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
my_memmove(arr + 3, arr + 1, 20);
int sz = sizeof(arr) / sizeof(arr[0]);
print(arr, sz);//数组的打印
printf("\n");
return 0;
}
10.手写函数:atoi(),atof()
- atoi()函数
剑指 Offer 67. 把字符串转换成整数 - 力扣(Leetcode)
功能:将字符串转换成整型数;atoi()会扫描参数nptr字符串,跳过前面的空格字符,直到遇上数字或正负号才开始做转换,而再遇到非数字或字符串时('\0')才结束转化,并将结果返回(返回转换后的整型数)。
#include <bits/stdc++.h>
using namespace std;
int my_atoi(char* str){
assert(str);
long long ans=0;
int sign=1;
while(*str==' ') str++;
if(*str=='-'){sign=-1;str++;}
if(*str=='+')str++;
while(*str>='0'&&*str<='9')
{
ans=ans*10+sign*(*str-'0');
str++;
if(ans>0 && ans > INT_MAX)ans =INT_MAX;
else if(ans<0&& ans < INT_MIN)ans =INT_MIN;
}
return ans;
}
-
函数 atof()
用于将字符串转换为双精度浮点数(double)
#include <bits/stdc++.h>
using namespace std;
double my_atof(const char *str)
{
double s=0.0;
double d=10.0;
int jishu=0;
bool falg=false;
while(*str==' ')
{
str++;
}
if(*str=='-')//记录数字正负
{
falg=true;
str++;
}
if(!(*str>='0'&&*str<='9'))//如果一开始非数字则退出,返回0.0
return s;
while(*str>='0'&&*str<='9'&&*str!='.')//计算小数点前整数部分
{
s=s*10.0+*str-'0';
str++;
}
if(*str=='.')//以后为小数部分
str++;
while(*str>='0'&&*str<='9')//计算小数部分
{
s=s+(*str-'0')/d;
d*=10.0;
str++;
}
if(*str=='e'||*str=='E')//考虑科学计数法
{
str++;
if(*str=='+')
{
str++;
while(*str>='0'&&*str<='9')
{
jishu=jishu*10+*str-'0';
str++;
}
while(jishu>0)
{
s*=10;
jishu--;
}
}
if(*str=='-')
{
str++;
while(*str>='0'&&*str<='9')
{
jishu=jishu*10+*str-'0';
str++;
}
while(jishu>0)
{
s/=10;
jishu--;
}
}
}
return s*(falg?-1.0:1.0);
}
11.写一个宏定义,实现输入两个参数并返回较小的一个
#define Min(X, Y) ((X)>(Y)?(Y):(X)) //宏是简单替换(最好用括号,避免优先级问题)
12.写一个宏定义,不用中间变量,实现两变量的交换
与下面交换两变量的值一样
#define swap(x,y) \
x=x+y;\
y=x-y;\
x=x-y //注意:结尾没有分号
13.已知数组table,用宏求数组元素个数
#define COUNT(table) (sizeof(table) / sizeof(table[0]))
14.用C语言实现读写寄存器变量
#define rBANKCON0 (*(volatile unsigned long *)0x48000004)
rBANKCON0 = 0x12;
(1)由于是寄存器地址,所以需要先将其强制类型转换为 ”volatile unsigned long *”。
(2)由于后续需要对寄存器直接赋值,所以需要解引用。
15.定义和声明的区别,未定义在编译哪个阶段报错
如果是指变量的声明和定义: 从编译原理上来说,声明是仅仅告诉编译器,有个某类型的变量会被使用,但是编译器并不会为它分配任何内存。而定义就是分配了内存。
如果是指函数的声明和定义: 声明:一般在头文件里,对编译器说:这里我有一个函数叫function() 让编译器知道这个函数的存在。 定义:一般在源文件里,具体就是函数的实现过程写明函数体。
未定义在编译哪个阶段报错
函数的声明和定义_c++未声明和未定义_明朗晨光的博客-CSDN博客
变量未声明的错误产生于 “编译” 阶段,编译阶段检查的是语法错误 变量未定义的错误产生于 “链接” 阶段,链接阶段关心的是怎么实现
函数未声明的错误产生于 “编译” 阶段,编译阶段检查的是语法错误 函数未定义的错误产生于 “链接” 阶段,链接阶段关心的是怎么实现
16.交换两个变量的值,不使用第三个变量。
可以使用算术运算符和位运算符来实现不使用第三个变量交换两个变量的值。
使用算术运算符:
void swap(int* a, int* b) {
*a = *a + *b;
*b = *a - *b;
*a = *a - *b;
}
使用位运算符:
void swap(int* a, int* b) {
*a = *a ^ *b;
*b = *a ^ *b;
*a = *a ^ *b;
}
这两种方法都是通过异或运算实现交换的,其中使用算术运算符的方法需要注意数据类型的范围,避免溢出。
17.指定位置1清零
给定一个整型变量a,写两段代码,第一个设置a的bit 3,第二个清除a 的bit 3。在以上两个操作中,要保持其它位不变。
#define BIT3 (0x1<<3)
static int a;
void set_bit3(void)
{
a |= BIT3;
}
void clear_bit3(void)
{
a &= ~BIT3;
}
18.指定位反转
在一个多任务嵌入式系统中,有一个CPU可直接寻址的32位寄存器REGn,地址为0x1F000010,编写一个安全的函数将寄存器REGn的指定位反转?
void bit_reverse(uint32_t nbit)
{
*((volatile unsigned int *)0x1F000010) ^= (0x01 << nbit);
}
-
指定位反转用异或^。
-
由于是寄存器地址,因此强制类型转换的时候要加上volatile。
19.为什么一般C程序中不用goto
- 容易产生混乱的代码:goto语句可能会使代码流程变得难以理解,因为它们允许程序跳转到程序的任何位置。这可能会导致代码的阅读和理解变得困难,特别是当程序规模较大时。
- 可能导致错误:goto语句容易导致一些常见的错误,例如使用未初始化的变量或无限循环。
- 可能难以维护:由于goto语句可以在程序中跳转到任意位置,因此在修改程序时可能需要在多个位置进行修改,这可能导致代码难以维护。
20.死循环有几种方式来写
在C语言中,可以使用while(1)
或for(;;)
语句来创建无限循环。
例如:
while(1) {
// 循环体语句
}
// 或者
for(;;) {
// 循环体语句
}
这两种方式都可以创建一个不会停止的循环,程序将一直在循环体内执行,直到出现某些特殊情况(比如程序异常终止、用户强制退出等)。在无限循环中,通常会添加一些条件判断语句,以便在特定条件下跳出循环。
除了使用while(1)
或for(;;)
语句外,还有其他一些实现无限循环的方式,例如:
- 使用
do-while
循环:
do {
// 循环体语句
} while(1);
do-while
循环与while
循环的区别在于,do-while
循环至少会执行一次循环体语句,然后再判断循环条件是否为真。
- 使用递归函数:
void loop() {
// 循环体语句
loop();
}
int main() {
loop();
return 0;
}
递归函数调用自身,可以实现类似于无限循环的效果。需要注意的是,递归函数调用层数过多可能会导致栈溢出等问题。
无论是使用哪种方式实现无限循环,都应该注意程序的安全性和健壮性,避免出现死循环、内存泄漏等问题。
另外还有一种实现无限循环的方式是使用goto
语句,这种方式不太常用,也容易出现代码混乱的情况,不推荐使用。示例如下:
loop:
// 循环体语句
goto loop;
这种方式利用了goto
语句的特性,将代码跳转到标记位置进行循环。然而,使用goto
语句容易出现代码混乱、可读性差等问题,不建议使用。
02.内存分配
21.C内存分配
#include <stdio.h>
const int g_A = 10; //常量区
int g_B = 20; //数据段
static int g_C = 30; //数据段
static int g_D; //BSS段
int g_E; //BSS段
char *p1; //BSS段
int main()
{
int local_A; //栈
int local_B; //栈
static int local_C = 0; //BSS段(初值为0 等于没初始化,会放在BSS段 )
static int local_D; //数据段
char *p3 = "123456"; //123456在代码段,p3在栈上
p1 = (char *)malloc( 10 ); //堆,分配得来得10字节的区域在堆区
char *p2 = (char *)malloc( 20 ); //堆上再分配,向上生长
strcpy( p1, "123456" ); //123456放在常量区,编译器可能会将它与p3所指向的"123456"优化成一块
printf("hight address\n");
printf("-------------栈--------------\n");
printf( "栈, 局部变量, local_A, addr:0x%08x\n", &local_A );
printf( "栈, 局部变量,(后进栈地址相对local_A低) local_B, addr:0x%08x\n", &local_B );
printf("-------------堆--------------\n");
printf( "堆, malloc分配内存, p2, addr:0x%08x\n", p2 );
printf( "堆, malloc分配内存, p1, addr:0x%08x\n", p1 );
printf("------------BSS段------------\n");
printf( "BSS段, 全局变量, 未初始化 g_E, addr:0x%08x\n", &g_E, g_E );
printf( "BSS段, 静态全局变量, 未初始化, g_D, addr:0x%08x\n", &g_D );
printf( "BSS段, 静态局部变量, 未初始化, local_C, addr:0x%08x\n", &local_C);
printf( "BSS段, 静态局部变量, 未初始化, local_D, addr:0x%08x\n", &local_D);
printf("-----------数据段------------\n");
printf( "数据段,全局变量, 初始化 g_B, addr:0x%08x\n", &g_B);
printf( "数据段,静态全局变量, 初始化, g_C, addr:0x%08x\n", &g_C);
printf("-----------代码段------------\n");
printf( " 常量区 只读const, g_A, addr:0x%08x\n\n", &g_A);
printf( " 程序代码,可反汇编看 objdump -d a.out \n");
printf("low address\n");
return 0;
}
22.堆与栈区别
-
管理方式:对于栈来讲,是由编译器自动管理,无需我们手工控制;对于堆来说,释放工作由程序员控制,容易产生memory leak。
-
空间大小:一般来讲在32位系统下,堆内存可以达到4G的空间,从这个角度来看堆内存几乎是没有什么限制的(堆:堆是向高地址扩展的数据结构,是不连续的内存区域。这是由于系统是用链表来存储的空闲内存地址的,自然是不连续的,而链表的遍历方向是由低地址向高地址。堆的大小受限于计算机系统中有效的虚拟内存。由此可见,堆获得的空间比较灵活,也比较大。)但是对于栈来讲,一般都是有一定的空间大小的(栈:在Windows下,栈是向低地址扩展的数据结构,是一块连续的内存的区域。这句话的意思是栈顶的地址和栈的最大容量是系统预先规定好的),如果申请的空间超过栈的剩余空间时,将提示overflow。因此,能从栈获得的空间较小。
在 Windows下,栈的大小是2M(也有的说是1M,总之是一个编译时就确定的常数),
在VC6下面,默认的栈空间大小是1M。
Linux下默认的用户栈空间大小是8MB,内核栈空间大小是8KB。Linux进程栈空间大小 - Tiehichi's Blog
-
碎片问题:对于堆来讲,频繁的new/delete会造成内存空间的不连续,从而造成大量的碎片,使程序效率降低。栈不会存在这个问题,因为栈是先进后出的。
-
分配方式:堆都是动态分配的,没有静态分配的堆。栈有2种分配方式:静态分配和动态分配。静态分配是编译器完成的,比如局部变量的分配。动态分配由malloca函数进行分配,但是栈的动态分配和堆是不同的,他的动态分配是由编译器进行释放,无需手工实现。
-
生长方向:堆,向上生长,也就是向着内存地址增加的方向;栈,向下生长,是向着内存地址减小的方向增长。
-
分配效率:栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高(只要栈的剩余空间大于所申请空间,系统将为程序提供内存,否则将报异常提示栈溢出)。堆则是C/C++函数库提供的,它的机制是很复杂的,例如为了分配一块内存,库函数会按照一定的算法(操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时,会遍历该链表,寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序。由于找到的堆结点的大小不一定正好等于申请的大小,系统会自动的将多余的那部分重新放入空闲链表中。) 在堆内存中搜索可用的足够大小的空间,如果没有足够大小的空间(可能是由于内存碎片太多),就有可能调用系统功能去增加程序数据段的内存空间,这样就有机会分到足够大小的内存,然后进行返回。显然,堆的效率比栈要低得多。
记忆:管小片,方长效率
23.在局部数组中定义一个大数组可以吗?很大的数组,比如2048
不可以,会爆栈,栈溢出
在Windows下,栈是向低地址扩展的数据结构,是一块连续的内存的区域。这句话的意思是栈顶的地址和栈的最大容量是系统预先规定好的,例如,在 WINDOWS下,栈的大小是2M(也有的说是1M,总之是一个编译时就确定的常数),在VC6下面,默认的栈空间大小是1M。当然,这个值可以修改。如果申请的空间超过栈的剩余空间时,将提示overflow。因此,能从栈获得的空间较小。
局部数组,具有局部作用域,当函数调用结束之后,数组也就被操作系统销毁了,即回收了他的内存空间。
解决方法,(解决局部大数组爆栈和局部作用域的问题)
- 定义成全局数组
- 加static放在静态存储区
char *fun()
{
static char a[] = "hello,world";
return a;
}
- 数组用malloc申请空间放在堆区
定义一个指针指向这个数组,栈中只占用一个指针的大小
char *fun()
{
char *a = (char*)malloc(sizeof(char)*100);
a = "hello,world";
return a;
}
24.在1G内存的计算机中能否通过malloc申请大于1G的内存?为什么?
可以 因为malloc函数是在程序的虚拟地址空间申请的内存,与物理内存没有直接的关系。虚拟地址与物理地址之间的映射是由操作系统完成的,操作系统可通过虚拟内存技术扩大内存。
25.malloc new的区别
malloc
和new
都可以用于在堆上分配内存空间,但它们的行为和用法是有所不同的。
分配空间的大小
malloc
函数的参数是所需空间的字节数,而new
关键字的参数是要分配空间的数据类型。在使用new
关键字时,编译器会自动计算所需空间的大小,并分配足够的内存空间。
返回值
malloc
函数返回的是分配内存空间的起始地址,通常需要将该地址进行强制类型转换,才能使用它。
new
关键字返回的是指向分配的空间的指针,不需要进行类型转换,可以直接使用。
内存分配失败的处理
malloc
函数在分配内存空间失败时,会返回一个空指针NULL
,需要程序员手动检查返回值来判断是否分配成功。
new
关键字在分配内存空间失败时,会抛出一个std::bad_alloc
异常,程序员需要通过try...catch
语句来捕获该异常。
内存空间的初始化
malloc
函数分配的内存空间并不会进行初始化,它返回的是一段未初始化的内存区域。
new
关键字分配的内存空间会进行初始化,对于内置类型,会进行默认初始化,而对于自定义类型,会调用构造函数进行初始化。
内存空间的释放
malloc
函数分配的内存空间需要使用free
函数进行手动释放。
new
关键字分配的内存空间需要使用delete
运算符进行手动释放,而对于数组类型,需要使用delete[]
运算符进行释放。
记忆:大回失始释
还有很多不同点,不详细总结了,能说几个不错了
分配内存的位置 | 自由存储区 | 堆 |
内存分配失败返回值 | 完整类型指针 | void* |
内存分配失败返回值 | 默认抛出异常 | 返回NULL |
分配内存的大小 | 由编译器根据类型计算得出 | 必须显式指定字节数 |
处理数组 | 有处理数组的new版本new[] | 需要用户计算数组的大小后进行内存分配 |
已分配内存的扩充 | 无法直观地处理 | 使用realloc简单完成 |
是否相互调用 | 可以,看具体的operator new/delete实现 | 不可调用new |
分配内存时内存不足 | 客户能够指定处理函数或重新制定分配器 | 无法通过用户代码进行处理 |
函数重载 | 允许 | 不允许 |
构造函数与析构函数 | 调用 | 不调用 |
放个示例
// 使用malloc分配内存空间
int* p1 = (int*)malloc(sizeof(int));
*p1 = 10;
free(p1); // 释放内存空间
// 使用new关键字分配内存空间
int* p2 = new int(20);
delete p2; // 释放内存空间
// 使用new关键字分配数组内存空间
int* p3 = new int[3] { 30, 40, 50 };
delete[] p3; // 释放内存空间
// 使用new关键字分配自定义类型的内存空间
Person* p4 = new Person();
delete p4;
26.malloc与free的实现原理?
malloc 背后的虚拟内存 和 malloc实现原理 - 知乎 (zhihu.com)
malloc
和free
是C语言中用于动态内存分配和释放的函数。它们的底层实现依赖于操作系统提供的系统调用,例如brk
或mmap
等。
当调用malloc
函数时,它会向操作系统请求一块连续的内存空间,该空间的大小由用户传递给malloc
函数的参数决定。操作系统会寻找一块足够大的空闲内存区域,并将其标记为已分配状态,然后将该内存区域的起始地址返回给调用者。
当调用free
函数时,它会将之前通过malloc
函数分配的内存空间释放回操作系统。free
函数并不会直接将内存空间返回给操作系统,而是将其标记为未分配状态,以便后续的malloc
函数可以再次使用该内存空间。在某些情况下,操作系统会将标记为未分配状态的内存空间合并成更大的空闲内存区域,以便后续的内存分配请求可以得到更大的内存空间。
27.new和delete的实现原理, delete是如何知道释放内存的大小的?
new 的实现原理:当程序使用 new 操作符时,编译器会生成一段代码来执行以下操作:
- 调用 operator new 函数,该函数会在堆中分配一块内存。
- 调用对象的构造函数,初始化对象。
- 返回指向该对象的指针。
delete 的实现原理:当程序使用 delete 操作符时,编译器会生成一段代码来执行以下操作:
- 调用对象的析构函数,释放对象占用的资源。
- 调用 operator delete 函数,将内存释放回堆。
delete 如何知道释放内存的大小:delete 操作符并不知道要释放的内存大小,它只需要知道要释放的指针地址。当对象被 new 分配内存时,编译器会在堆中存储有关对象大小的信息,包括对象的长度和其他元数据。当使用 delete 操作符释放对象时,编译器使用这些元数据来确定要释放的内存块的大小。因此,如果在使用 new 时使用了错误的长度,可能会导致 delete 操作符释放错误的内存块,从而引起程序错误或崩溃。
28.在成员函数中调用delete this会出现什么问题?对象还可以使用吗?
在类对象的内存空间中,只有数据成员和虚函数表指针,并不包含代码内容,类的成员函数单独放在代码段中。在调用成员函数时,隐含传递一个this指针,让成员函数知道当前是哪个对象在调用它。在成员函数中调用delete this
会导致对象被立即销毁并释放其内存。这种做法非常危险,因为一旦对象被销毁,它的成员变量就会变成未定义的值,进而导致未定义行为。而且,在成员函数中调用delete this
通常是无法撤销的,在delete this之后进行的其他任何函数调用,只要不涉及到this指针的内容,都能够正常运行。一旦涉及到this指针,如操作数据成员,调用虚函数等,就会出现不可预期的问题/未定义行为。
为什么是不可预期的问题?
delete this之后不是释放了类对象的内存空间了么,那么这段内存应该已经还给系统,不再属于这个进程。照这个逻辑来看,应该发生指针错误,无访问权限之类的令系统崩溃的问题才对啊?这个问题牵涉到操作系统的内存管理策略。delete this释放了类对象的内存空间,但是内存空间却并不是马上被回收到系统中,可能是缓冲或者其他什么原因,导致这段内存空间暂时并没有被系统收回。此时这段内存是可以访问的,你可以加上100,加上200,但是其中的值却是不确定的。当你获取数据成员,可能得到的是一串很长的未初始化的随机数;访问虚函数表,指针无效的可能性非常高,造成系统崩溃。
因此,一般情况下不建议在成员函数中调用delete this
。如果要销毁对象,可以通过其他方式来实现,例如在对象外部调用delete
操作符,或者使用智能指针等方式来管理对象的生命周期。如果需要在对象的成员函数中销毁对象,可以考虑采用延迟销毁的方式,即将对象加入到一个队列中,在对象的成员函数执行完毕后再由另一个线程或者定时器来销毁对象。
29.既然有了malloc/free,C++中为什么还需要new/delete呢?
这是因为new
和delete
是C++中的运算符,不仅可以分配内存空间,还可以自动调用对象的构造函数和析构函数来进行对象的初始化和销毁。这些操作可以帮助程序员更方便地管理对象的生命周期,避免内存泄漏和其他内存相关问题。此外,new
和delete
还支持类的继承、多态等高级特性,可以方便地创建和销毁对象的派生类实例。
C++中的new
和delete
可以使用重载的方式来实现自定义的内存分配和释放操作,这在一些特殊情况下非常有用。例如,可以重载new
和delete
来实现内存池、对象池等高效的内存管理方案,提高程序的性能和可维护性。
30.C++中类的数据成员和成员函数内存分布情况
C++类是由结构体发展得来的,所以他们的成员变量(C语言的结构体只有成员变量)的内存分配机制是一样的。
一个类对象的地址就是类所包含的这一片内存空间的首地址,这个首地址也就对应具体某一个成员变量的地址。(在定义类对象的同时这些成员变量也就被定义了),举个例子:
#include <iostream>
using namespace std;
class Person
{
public:
Person()
{
this->age = 23;
}
void printAge()
{
cout << this->age <<endl;
}
~Person(){}
public:
int age;
};
int main()
{
Person p;
cout << "对象地址:"<< &p <<endl;
cout << "age地址:"<< &(p.age) <<endl;
cout << "对象大小:"<< sizeof(p) <<endl;
cout << "age大小:"<< sizeof(p.age) <<endl;
return 0;
}
//输出结果
//对象地址:0x7fffec0f15a8
//age地址:0x7fffec0f15a8
//对象大小:4
//age大小:4
从代码运行结果来看,对象的大小和对象中数据成员的大小是一致的,也就是成员函数不占用对象的内存。这是因为所有的函数都是存放在代码区的,不管是全局函数,还是成员函数。要是成员函数占用类的对象空间,那么将是多么可怕的事情:定义一次类对象就有成员函数占用一段空间。
总结:
在C++中,类的数据成员存储在对象的内存中。具体地,类的数据成员按照声明的顺序依次存储在对象的内存中,通常按照字节对齐的规则进行存储。例如,如果一个类的数据成员包含一个整型和一个字符型,则这个类的对象在内存中的存储顺序通常是先存储整型,然后存储字符型,两者之间可能存在填充字节以满足字节对齐的要求。
**对于成员函数,它们并不存储在类的对象中,而是存储在代码段中。**成员函数可以通过类的对象或类的指针来访问类的数据成员。在C++中,成员函数可以分为两种类型:普通成员函数和静态成员函数。普通成员函数的调用需要通过类的对象或类的指针进行,而静态成员函数可以通过类名直接调用,不需要实例化类的对象。所有函数都存放在代码区,静态函数也不例外。有人一看到 static 这个单词就主观的认为是存放在全局数据区,那是不对的。
除了数据成员和成员函数,类的对象还包含了一些额外的元数据,如虚函数表指针等。虚函数表指针是一个指向虚函数表的指针,用于实现多态性。虚函数表是一个存储着类的虚函数指针的表,每个类只有一个虚函数表,其中包含了类的所有虚函数的地址。当一个类被继承时,子类会继承父类的虚函数表,并在其中添加自己的虚函数。虚函数表指针存储在类的对象的内存开头位置(即第一个字节),这个指针的大小和位数与机器的位数和操作系统有关。
31.请说一下以下几种情况下,下面几个类的大小各是多少?
空类的大小是多少?
- C++空类的大小不为0,不同编译器设置不一样,vs设置为1;
- C++标准指出,不允许一个对象(当然包括类对象)的大小为0,不同的对象不能具有相同的地址;
- 带有虚函数的C++类大小不为1,因为每一个对象会有一个vptr指向虚函数表,具体大小根据指针大小确定;
- C++中要求对于类的每个实例都必须有独一无二的地址,那么编译器自动为空类分配一个字节大小,这样便保证了每个实例均有独一无二的内存地址。
class A {};
int main(){
cout<<sizeof(A)<<endl;// 输出 1;
A a;
cout<<sizeof(a)<<endl;// 输出 1;
return 0;
}
空类的大小是1, 在C++中空类会占一个字节,这是为了让对象的实例能够相互区别。具体来说,空类同样可以被实例化,并且每个实例在内存中都有独一无二的地址,因此,编译器会给空类隐含加上一个字节,这样空类实例化之后就会拥有独一无二的内存地址。当该空白类作为基类时,该类的大小就优化为0了,子类的大小就是子类本身的大小。这就是所谓的空白基类最优化C/C++编程:空基类优化_OceanStar的学习笔记的博客-CSDN博客。
空类的实例大小就是类的大小,所以sizeof(a)=1字节.
class A { virtual Fun(){} };
int main(){
cout<<sizeof(A)<<endl;// 输出 4(32位机器)/8(64位机器);
A a;
cout<<sizeof(a)<<endl;// 输出 4(32位机器)/8(64位机器);
return 0;
}
因为有虚函数的类对象中都有一个虚函数表指针 __vptr,其大小是4字节
class A { static int a; };
int main(){
cout<<sizeof(A)<<endl;// 输出 1;
A a;
cout<<sizeof(a)<<endl;// 输出 1;
return 0;
}
静态成员存放在静态存储区,不占用类的大小, 普通函数也不占用类大小
class A { int a; };
int main(){
cout<<sizeof(A)<<endl;// 输出 4;
A a;
cout<<sizeof(a)<<endl;// 输出 4;
return 0;
}
class A { static int a; int b; };
int main(){
cout<<sizeof(A)<<endl;// 输出 4;
A a;
cout<<sizeof(a)<<endl;// 输出 4;
return 0;
}
静态成员a不占用类的大小,所以类的大小就是b变量的大小即4个字节。
32.什么是内存泄露,如何检测与避免
内存泄露
一般我们常说的内存泄漏是指堆内存的泄漏。堆内存是指程序从堆中分配的,大小任意的(内存块的大小可以在程序运行期决定)内存块,使用完后必须显式释放的内存。应用程序般使用malloc,、realloc、 new等函数从堆中分配到块内存,使用完后,程序必须负责相应的调用free或delete释放该内存块,否则,这块内存就不能被再次使用,我们就说这块内存泄漏了。
简单地说就是申请了一块内存空间,使用完毕后没有释放掉。 它的一般表现方式是程序运行时间越长,占用内存越多,最终用尽全部内存,整个系统崩溃。由程序申请的一块内存,且没有任何一个指针指向它,那么这块内存就泄露了。
常见的内存泄露方式
- 未配对
char *pt = (char *)malloc(10); //堆上申请空间,未配对free(pt)
- 丢失地址
char *pt= (char *)malloc(10);
pt= (char *)malloc(20); //覆盖了指针,导致前面10个空间的地址丢失。
- 未分级释放
#include <stdio.h>
#include <stdlib.h>
struct birth
{
int year;
int month;
int day;
};
struct student
{
char is_male;
char *name;
struct birth * bi;
};
int main()
{
struct student *pt;
pt= (struct student *)malloc(sizeof(struct student)); //堆上申请空间
pt->is_male =1;
pt->name ="wangwei";
pt->bi = (struct birth *)malloc(sizeof(struct birth)); //堆上申请空间
pt->bi->year =2000;
pt->bi->month =3;
pt->bi->day =2;
printf("%s %d \n",pt->name,pt->bi->day);
//逐级释放空间,避免内存泄漏
//pt->name 是字符串常量 不用释放
if(pt->bi!=NULL){
free(pt->bi); //先释放子空间
pt->bi=NULL;
}
free(pt); //后释放父空间
pt =NULL; //避免野指针 (操作已释放的空间)
return 0;
}
避免内存泄露的几种方式
- 显式释放内存:程序在使用动态分配的内存时,应该及时使用free函数将不再需要的内存释放掉。需要注意的是,释放的内存必须是程序动态分配的内存,而不是栈空间中的局部变量。有new就有delete,有malloc就有free,保证它们一定成对出现
- 避免重复分配内存:程序在使用动态分配的内存时,应该避免重复分配内存,特别是在循环中。如果需要多次分配内存,可以使用realloc函数重新调整内存块的大小,以减少内存碎片的产生。
- 使用智能指针:智能指针是一种自动管理内存的工具,可以避免手动释放内存的繁琐操作。智能指针会在对象不再被使用时自动释放内存,并且可以避免内存泄漏和悬空指针等问题。
- 计数法:使用new或者malloc时,让该数+1,delete或free时,该数-1,程序执行完打印这个计数,如果不为0则表示存在内存泄露
- 一定要将基类的析构函数声明为虚函数
- 对象数组的释放一定要用delete []
- 使用内存检测工具:内存检测工具可以帮助程序员检测内存泄漏和内存访问越界等问题,提高程序的健壮性和可靠性。常见的内存检测工具包括Valgrind、AddressSanitizer等。
检测工具
- Valgrind:是一款免费的内存检测工具,可以检测内存泄漏、内存访问越界、使用未初始化的内存等问题。Valgrind可以运行在Linux、Mac OS X等操作系统上,并且支持多种编程语言,包括C、C++、Java等。
- AddressSanitizer:是一款由Google开发的内存检测工具,可以检测内存泄漏、内存访问越界等问题。AddressSanitizer可以在编译时加入编译选项,支持多种编程语言,包括C、C++、Rust等。
- Electric Fence:是一款免费的内存检测工具,可以检测内存泄漏、内存访问越界等问题。Electric Fence使用了一种内存保护技术,会将分配的内存区域的前后加上一个特殊的标记,当程序访问这些标记时就会触发异常,从而帮助程序员及时发现内存问题。
- Purify:是一款商业的内存检测工具,可以检测内存泄漏、内存访问越界等问题。Purify支持多种编程语言,包括C、C++、Java等,并且可以运行在多个操作系统上,包括Linux、Windows、AIX等。
33.常见字节对齐类型
1、逐段对齐
typedef struct stu
{
char sex; //1
short num; //2
int age; //4
}stu;
// 1+2=3补1 + 4 =8(是最大4的倍数)
//4(1+2+padding)+4 = 8bytes
typedef struct stu
{
char sex; //1
int age; //4
short num; //2
}stu;
//1补3 + 4 + 2补2 =12(是最大4的倍数)
//4(1+padding)+4+4(2+padding) = 12bytes
2、带位数指定的逐段对齐
例1
struct A
{
char t : 4; // 4位
char k : 4; // 4位
unsigned short i : 8; // 8位
unsigned long m; // 4字节
};
//根据结构体内存对齐原则,共占用8字节。
//0.5+0.5+1=2补2=4+4=8
//4(0.5+0.5+1+padding)+4 = 8bytes
例2
struct s
{
int i: 8; //占int型里的8位
int j: 4; //占int型里的4位,前两个一起可以占4个字节,再补4字节
double b; //8字节,最大,其他的向他看齐,不让他跨越空间,读的时候一下就读出来了
int a:3; //3位,可以用4个字节,再补4个字节
}; //4补4 + 8 + 4补4 = 24 逐段对齐
printf("sizeof(s)= %d\n", sizeof(struct s));
//8(i+j+padding)+8(b)+4(a+padding)+4(padding) = 24 bytes
//这种带位数指定的逐段对齐的写法是原来单片机中方便操作寄存器使用的。
3、#pragma pack (value)时指定的对齐
#pragma pack(1)
struct fun
{
int i; // 4字节
double d; // 8字节
char c; // 1字节
};
//sizeof(fun)得到的结果是13。
//因为预处理语句 ”#prama pack(1)” 将编译器的字节对齐数改为1了,根据结构体内存对齐原则,该结构体占用的字节数为13。
4、带联合体的对齐
例1
typedef struct stu
{
int num; //4
char name[30]; //30
char job; //1 后面都是8,前面全配成8的倍数就行>35的就40个字节了
double sex; //8
union //只分配最大元素的空间(8)
{
int grade; //4
double d; //8 类型最大值
}gorp;
}stu;
//8(4+padding)+32(30+1+padding)+8+8 = 56 bytes
例2
typedef union
{
long i; //8
int k[5]; //20
char c; //1
} DATE;
struct data
{
int cat; //4
DATE cow; //这里是20,但是按DATA元素的最大对齐,即struct data按8字节对齐
double dog; //8
} too;
DATE max;
则语句 printf("%d",sizeof(struct data)+sizeof(max));的执行结果是___,
解答
typedef union //最大元素空间 为20
{
long i; //8
int k[5]; //4*5=20
char c; //1
} DATE;
struct data
{
int cat; //4
DATE cow; //20 里面虽然有long,但是对外是20字节int的数据
double dog; //8 看这个类型的整数倍
} too;
DATE max; //20
//4+20(刚好是8的倍数,前面的两个都不用各自补齐)+8 = 32 bytes
//则语句 printf("%d",sizeof(struct data)+sizeof(max));的执行结果是32+20=52。
03.指针
34.指针的指针用途
指针的指针具有以下用途:
-
**动态分配内存:**使用指针的指针可以动态分配内存并将其传递给函数,以便函数可以修改指向该内存的指针。这样可以避免在函数中使用全局变量,提高程序的模块化程度。
#include <stdio.h> #include <stdlib.h> void allocate_memory(int** pptr) { *pptr = (int*)malloc(sizeof(int)); **pptr = 10; } int main() { int* ptr; allocate_memory(&ptr); printf("%d\n", *ptr); free(ptr); return 0; }
在上面的示例中,函数
allocate_memory
接受一个指向指针的指针pptr
,并将一个指向动态分配的内存的指针赋值给它。在main
函数中,我们声明了一个指向int
类型的指针ptr
,并将其作为参数传递给allocate_memory
函数,从而将指向动态分配的内存的指针传递回来。最后我们输出指针指向的内存的值并释放内存。 -
函数参数传递:指针的指针也可以被用来传递函数参数。在C语言中,函数参数默认是按值传递的,这意味着在函数中修改参数的值不会影响调用方的参数。但是如果将指向指针的指针作为函数参数,就可以在函数内部修改指针的指针指向的内存,从而影响调用方的指针。
#include <stdio.h> #include <stdlib.h> void change_ptr(int **ptr_ptr) { int *ptr = malloc(sizeof(int)); *ptr = 100; *ptr_ptr = ptr; } int main() { int num = 10; int *ptr = # printf("ptr points to value: %d\n", *ptr); change_ptr(&ptr); printf("ptr now points to value: %d\n", *ptr); free(ptr); return 0; }
在这个示例代码中(其实和第一个一样),我们使用
malloc
函数动态分配了一块内存,并将值100存储在这块内存中。然后将指向该内存的指针赋值给指向指针的指针,从而在函数返回后,ptr
仍然可以访问该内存中存储的值。最后我们使用free
函数释放了分配的内存。这个示例代码展示了如何使用指向指针的指针来传递函数参数,并在函数内部动态分配内存并返回指向该内存的指针。这种技巧可以被用于很多不同的应用场景,例如实现动态数组、动态链表等数据结构。
-
二维数组/多级访问:指针的指针可以被用来处理二维数组。在C语言中,二维数组实际上是一个连续的内存块,可以通过指向指针的指针来处理。通过指向指针的指针,可以实现对二维数组中每个元素的动态访问。
#include <stdio.h> int main() { int a[2][3] = {{1, 2, 3}, {4, 5, 6}}; int (*pptr)[3] = a; // 指向指针的指针【数组指针】 printf("%d\n", *(*pptr + 1)); // 输出第一行第二个元素 2 printf("%d\n", *(*(pptr+1) + 1)); // 输出第二行第二个元素 5 return 0; }
在上面的示例中,我们声明了一个二维数组
a
,并将其赋值给一个指向指针的指针pptr
。由于pptr
是指向int[3]
类型的指针,因此可以使用*pptr
获取指向第一行的指针,使用*(*pptr + 1)
获取第一行第二个元素的值。 -
链表:链表是一个常见的数据结构,使用指针的指针可以在链表的添加、删除等操作中起到重要作用。
#include <stdio.h> #include <stdlib.h> struct node { int data; struct node* next; }; void add_node(struct node** head_ref, int new_data) { struct node* new_node = (struct node*)malloc(sizeof(struct node)); new_node->data = new_data; new_node->next = *head_ref; *head_ref = new_node; }
35.指针常量,常量指针,指向常量的常量指针有什么区别?
指针常量
int * const p
先看const再看 * ,p是一个常量类型的指针,不能修改这个指针p的指向,但是这个指针所指向的地址上存储的值可以修改。
常量指针
const int *p
int const *p
先看再看const,定义一个指针指向一个常量,不能通过指针来修改这个指针指向的值*p。
指向常量的常量指针
const int *const p
int const *const p
对于“指向常量的常量指针”,就必须同时满足上述1和2中的内容,既不可以修改指针的值,也不可以修改指针指向的值。
36.数组首元素地址a和数组地址&a有什么区别?
假设数组int a[10]; int (*p)[10] = &a;其中:
-
a是数组名,是数组首元素地址,+1表示地址值加上一个int类型的大小,如果a的值是0x00000001,加1操作后变为0x00000005。
a+1就是第二个元素的地址,*(a + 1) = a[1]。
-
&a是数组的指针,其类型为int (*)[10](就是下面提到的数组指针),&a+1,系统会认为是数组首地址加上整个数组的偏移(10个int型变量)就是向后移动(10 * 4)个单位,值为数组a尾元素后一个元素的地址。
若(int *)p ,此时输出 *p时,其值为a[0]的值,因为被转为int *类型,解引用时按照int类型大小来读取。
37.数组名和指针(这里为指向数组首元素的指针)区别?
数组名和指针的区别与联系是什么?
- 数组名是一个常量指针,它在程序执行过程中不会改变,指向的地址是数组的首地址。而指向数组首元素的指针可以被重新赋值,可以指向其他元素或者其他数组。
- 数组名不能进行指针运算,而指向数组首元素的指针可以进行指针运算,例如加减操作。这是因为数组名是一个常量指针,它指向的地址是不可修改的。
- 对数组名使用sizeof操作符时,返回的是整个数组占用内存的大小。对指向数组首元素的指针使用sizeof操作符时,返回的是指针类型的大小。
- 在函数调用时,数组名作为参数传递给函数时,它实际上是传递给函数的一个指针,即指向数组首元素的指针。因此,数组名和指向数组首元素的指针在作为函数参数传递时,可以互换使用。
- 数组名在声明时必须指定数组的大小,而指向数组首元素的指针在声明时可以不指定数组的大小。
常运s参
38.数组指针和指针数组有什么区别?
数组指针
数组指针就是指向数组的指针,它表示的是一个指针,这个指针指向的是一个数组,它的重点是指针。 例如,int(*pa)[8]
声明了一个指针,该指针指向了一个有8个int型元素的数组。下面给出一个数组指针的示例。
数组指针也称指向一维数组的指针,亦称行指针。
#include <stdio.h>
#include <stdlib.h>
void main() {
int b[12]={1,2,3,4,5,6,7,8,9,10,11,12};
int (*p)[4];
p = b; //(好像不对)
printf("%d\n", **(++p);
}
程序的输出结果为 5。
上例中,p是一个数组指针,它指向一个包含有4个int类型数组的指针,刚开始p被初始化为指向数组b 的首地址,++p相当于把p所指向的地址向后移动4个int所占用的空间,此时p指向数组{5,6,7,8},语句 *(++p);表示的是这个数组中第一个元素的地址(可以理解p为指向二维数组的指针,{1,2,3,4},
{5,6,7,8},{9,10,11,12}。p指向的就是{1,2,3,4}的地址,*p 就是指向元素,{1,2,3,4},**p 指向的就是1),语句**(++p)会输出这个数组的第一个元素5。
指针数组
指针数组表示的是一个数组,而数组中的元素是指针。
如命令行参数
#include <stdio.h>
//argc: 命令行参数个数
//argv: 用指针数组存储参数,第一个是执行文件的名字(a.out)
int main(int argc, char *argv[])
{
int i;
for (i = 1; i < argc; i++){
printf("%s ", argv[i]);
}
printf("\n");
return 0;
}
$ gcc main.c
$ ./a.out hello world //argc就是a.out argv是后面的参数
又如
char *arr[4] = {"hello", "world", "shannxi", "xian"};
//arr就是我定义的一个指针数组,它有四个元素,每个元素是一个char *类型的指针,这些指针存放着其对应字符串的首地址。
39.函数指针和指针函数有什么区别?
函数指针 如果在程序中定义了一个函数,那么在编译时系统就会为这个函数代码分配一段存储空间,这段存储空间的首地址称为这个函数的地址。而且函数名表示的就是这个地址。既然是地址我们就可以定义一个指针变量来存放,这个指针变量就叫作函数指针变量,简称函数指针。
int(*p)(int, int);
这个语句就定义了一个指向函数的指针变量 p。首先它是一个指针变量,所以要有一个“* ”,即(* p); 其次前面的 int 表示这个指针变量可以指向返回值类型为 int 型的函数;后面括号中的两个 int 表示这个指针变量可以指向有两个参数且都是 int 型的函数。所以合起来这个语句的意思就是:定义了一个指针变量 p,该指针变量可以指向返回值类型为 int 型,且有两个整型参数的函数。p 的类型为 int(*) (int,int) 。 我们看到,函数指针的定义就是将“函数声明”中的“函数名”改成“(指针变量名)”。但是这里需要注意的 是:“(指针变量名)”两端的括号不能省略,括号改变了运算符的优先级。如果省略了括号,就不是定义函数指针而是一个函数声明了,即声明了一个返回值类型为指针型的函数。 最后需要注意的是,指向函数的指针变量没有 ++ 和 -- 运算。
# include <stdio.h>
int Max(int x, int y)
{
return x>y?x:y;
}
int main(void) {
int(*p)(int, int); //定义一个函数指针
int a, b, c;
p = Max; //把函数Max赋给指针变量p, 使p指向Max函数
printf("please enter a and b:");
scanf("%d%d", &a, &b);
c = (*p)(a, b); //通过函数指针调用Max函数
//或者c=p(a, b); 【两种函数指针的调用方式】
printf("a = %d\nb = %d\nmax = %d\n", a, b, c);
return 0;
}
linux内核中的file_operation结构体中就是一大堆函数指针,具体操作函数编写后注册即可,用户在文件系统中调用系统调用函数的名字都是函数指针的名字。
struct file_operations {
struct module *owner;//拥有该结构的模块的指针,一般为THIS_MODULES
loff_t (*llseek) (struct file *, loff_t, int);//用来修改文件当前的读写位置
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);//从设备中同步读取数据
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);//向设备发送数据
ssize_t (*aio_read) (struct kiocb *, const struct iovec *, unsigned long, loff_t);//初始化一个异步的读取操作
ssize_t (*aio_write) (struct kiocb *, const struct iovec *, unsigned long, loff_t);//初始化一个异步的写入操作
int (*readdir) (struct file *, void *, filldir_t);//仅用于读取目录,对于设备文件,该字段为NULL
unsigned int (*poll) (struct file *, struct poll_table_struct *); //轮询函数,判断目前是否可以进行非阻塞的读写或写入
int (*ioctl) (struct inode *, struct file *, unsigned int, unsigned long); //执行设备I/O控制命令
long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long); //不使用BLK文件系统,将使用此种函数指针代替ioctl
long (*compat_ioctl) (struct file *, unsigned int, unsigned long); //在64位系统上,32位的ioctl调用将使用此函数指针代替
int (*mmap) (struct file *, struct vm_area_struct *); //用于请求将设备内存映射到进程地址空间
int (*open) (struct inode *, struct file *); //打开
int (*flush) (struct file *, fl_owner_t id);
int (*release) (struct inode *, struct file *); //关闭
int (*fsync) (struct file *, struct dentry *, int datasync); //刷新待处理的数据
int (*aio_fsync) (struct kiocb *, int datasync); //异步刷新待处理的数据
int (*fasync) (int, struct file *, int); //通知设备FASYNC标志发生变化
int (*lock) (struct file *, int, struct file_lock *);
ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int);
unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);
int (*check_flags)(int);
int (*flock) (struct file *, int, struct file_lock *);
ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int);
ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int);
int (*setlease)(struct file *, long, struct file_lock **);
};
struct file_operations fops = {
.read = device_read,
.write = device_write,
.open = device_open,
.release = device_release
};
指针函数 首先它是一个函数,只不过这个函数的返回值是一个地址值。函数返回值必须用同类型的指针变量来接受,也就是说,指针函数一定有“函数返回值”,而且,在主调函数中,函数返回值必须赋给同类型的指针变量。
类型名 *函数名(函数参数列表);
其中,后缀运算符括号“()”表示这是一个函数,其前缀运算符星号“*”表示此函数为指针型函数,其函数值为指针,即它带回来的值的类型为指针,当调用这个函数后,将得到一个“指向返回值为…的指针(地址),“类型名”表示函数返回的指针指向的类型”。 “(函数参数列表)”中的括号为函数调用运算符,在调用语句中,即使函数不带参数,其参数表的一对括号也不能省略。其示例如下:
int *pfun(int, int);
由于“*”的优先级低于“()”的优先级,因而pfun首先和后面的“()”结合,也就意味着,pfun是一个函数。 即:
int *(pfun(int, int));
接着再和前面的“*”结合,说明这个函数的返回值是一个指针。由于前面还有一个int,也就是说,pfun 是一个返回值为整型指针的函数。
#include <stdio.h>
float *find(float(*pionter)[4],int n);//函数声明
int main(void) {
static float score[][4]={{60,70,80,90},{56,89,34,45},{34,23,56,45}};
float *p;
int i,m;
printf("Enter the number to be found:");
scanf("%d",&m);
printf("the score of NO.%d are:\n",m);
p=find(score,m-1);
for(i=0;i<4;i++)
printf("%5.2f\t",*(p+i));
return 0;
}
float *find(float(*pionter)[4],int n)/*定义指针函数*/ {
float *pt;
pt=*(pionter+n);
return(pt);
}
共有三个学生的成绩,函数find()被定义为指针函数,其形参pointer是指针指向包含4个元素的一维数组的指针变量。pointer+n指向score的第n+1行。*(pointer+1)指向第一行的第0个元素。pt是一个指针变量,它指向浮点型变量。main()函数中调用find()函数,将score数组的首地址传给pointer。
40.指针和引用的异同是什么?
相同
- 都可以用于访问内存中的变量或对象。
- 都可以作为函数参数,传递变量或对象的引用或地址。
- 都可以用于动态分配内存,并在程序中进行内存管理。
- 都可以用于实现数据结构,如链表、树等。
- 都可以作为成员变量出现在类中,并用于实现类的数据成员和成员函数。
区别(区别很多,记这几个够了)
- 内存模型不同: 指针是一个变量,它存储着一个内存地址,而引用是一个别名,它是已经存在的变量或对象的别名。因此,指针本身占据内存空间,而引用不占用内存空间。
- 指针和引用的自增(++)运算符意义不同,指针是对内存地址自增,而引用是对值的自增。
- 指针需要解引用,引用使用时无需解引用(*)。
- 指针可变,引用只能在定义时被初始化一次,之后不可变。
- 指针可以为空,引用不能为空。
- “sizeof 指针”得到的是指针本身的大小,在32 位系统指针变量占用4字节内存,“sizeof 引用”得到的是所指向的变量(对象)的大小。
记忆:内增,可变空解S
#include "stdio.h"
int main(){
int x = 5;
int *p = &x;
int &q = x;
printf("%d %d\n",*p,sizeof(p));
printf("%d %d\n",q,sizeof(q));
}
//结果
5 8
5 4
由结果可知,引用使用时无需解引用(*),指针需要解引用;我用的是64位操作系统,“sizeof 指针”得到 的是指针本身的大小,及8个字节。而“sizeof 引用”得到的是的对象本身的大小及int的大小,4个字节。
41.为什么有了指针还需要引用
指针是一个变量,存储着内存地址,可以通过解引用操作符 *
来访问所指向的内存。指针可以为空(nullptr
),可以被重新赋值指向其他对象,甚至可以指向无效的内存地址。指针的优势在于它的灵活性和动态性,可以动态分配和释放内存,以及实现数据结构和动态数据结构的设计。指针也可以作为函数参数进行传递,从而实现在函数内部修改实参的值。
引用是一个别名,它为现有的对象提供了一个新的名称。引用必须在声明时初始化,并且不能被重新赋值引用其他对象。引用在语法上与被引用的对象相同,可以像使用对象本身一样使用引用。引用的优势在于它的简洁性和安全性,它提供了一种直接访问对象的方式,不需要解引用操作,同时不会涉及指针的复杂性和潜在的错误。
尽管指针和引用都可以用于在函数之间传递参数和访问对象,但它们有一些区别和适用场景:
-
空值(null value):指针可以为空,即指向空地址(
nullptr
),而引用必须始终引用一个有效的对象。当对象可能不存在或需要表示空值时,可以使用指针。例如,当函数需要返回一个可能为空的结果时,可以使用指针作为返回值。 -
重新赋值:指针可以被重新赋值指向其他对象,而引用一旦初始化后就不能被重新赋值。如果需要在函数内部修改实参的值,可以使用指针作为函数参数;如果只需要访问对象而不修改它,可以使用引用。
-
安全性和简洁语义:引用在语义上表示对现有对象的别名,不会产生空指针或无效引用的问题,因此引用相对更安全。同时,引用语义更直观和简洁,可以使代码更易读和易懂。
因此,指针和引用在不同的情况下具有不同的用途。指针提供了更大的灵活性和动态性,适用于需要动态分配内存、重新赋值、或表示可能为空的对象的情况。引用提供了更简洁和直接的访问方式,适用于只需访问对象而不需要重新赋值的情况。根据具体的需求和语义,可以选择使用指针或引用来满足编程的要求。
42.函数调用时传入参数为引用、指针、传值的区别
- 传值
在传值方式中,函数会将参数的值复制一份,并在函数内部使用这份复制品。这意味着函数内部对参数的任何修改都不会影响函数外部的原始参数。
- 传指针
在传指针方式中,函数会接收参数的地址,也就是指向参数内存位置的指针。这意味着函数内部可以直接访问原始参数,并进行修改。
- 传引用
在传引用方式中,函数会接收参数的引用,也就是指向参数的别名。这意味着函数内部可以直接访问原始参数,并进行修改,就像传指针一样。但与传指针不同的是,我们在函数调用时不需要使用取地址符&来获取参数的地址,而是直接使用参数本身。
43.C++中的指针传参和引用传参底层原理你知道吗?
指针传参
本质上是值传递,它所传递的是一个地址值。
值传递过程中,会在栈中开辟内存空间以存放由主调函数传递进来的实参值,从而形成了实参的一个副本(替身)。
值传递的特点是,被调函数对形式参数的任何操作都是作为局部变量进行的,不会影响主调函数的实参变量的值(形参指针变了,实参指针不会变)。如果改变被调函数中的指针地址本身,它将应用不到主调函数的相关变量。如果想通过指针参数传递来改变主调函数中的相关变量(地址),那就得使用指向指针的指针或者指针引用。
如果修改指针指向的地址的值,那就和引用传参一样。
引用传参
本质上是值传递,它所传递的是一个地址值,是由主调函数放进来的实参变量的地址。(引用底层是指针常量,指针本身指向不可变,指向的值可变)被调函数的形式参数也作为局部变量在栈中开辟了内存空间。被调函数对形参(本体)的任何操作都被处理成间接寻址,即通过栈中存放的地址访问主调函数中的实参变量(根据别名找到主调函数中的本体)。因此,被调函数对形参的任何操作都会影响主调函数中的实参变量。
从编译的角度来讲,程序在编译时分别将指针和引用添加到符号表上,符号表中记录的是变量名及变量所对应地址。
指针变量在符号表上对应的地址值为指针变量的地址值,而引用在符号表上对应的地址值为引用对象的地址值(与实参名字不同,地址相同)。符号表生成之后就不会再改,因此指针可以改变其指向的对象(指针变量中的值可以改),而引用对象则不能修改。
44.野指针是什么?
野指针是一种指针变量,它指向的内存地址已经不再被分配给该程序使用。
野指针通常会出现在以下情况下:
- 对未初始化的指针进行间接引用;
当指针被创建时,指针不可能自动指向NULL,这时,默认值是随机的,此时的指针成为野指针。
- 对已经释放的指针进行间接引用;
当指针被free或delete释放掉时,如果没有把指针设置为NULL,则会产生野指针(或叫悬空指针),因为释放掉的仅仅是指针指向的内存,并没有把指针本身释放掉。
- 对指向栈内存的指针在函数返回后进行间接引用;
45.如何避免野指针?
- 对指针进行初始化。
//将指针初始化为NULL。
char *p = NULL;
//用malloc分配内存
char * p = (char * )malloc(sizeof(char));
//用已有合法的可访问的内存地址对指针初始化
char num[30] = {0};
char *p = num;
- malloc函数分配完内存后需注意:
检查是否分配成功(若分配成功,返回内存的首地址;分配不成功,返回NULL。可以通过if语句来判断)
清空内存中的数据(malloc分配的空间里可能存在垃圾值,用memset或bzero 函数清空内存)
//s是需要置零的空间的起始地址; n是要置零的数据字节个数。
void bzero(void *s, int n);
//如果要清空空间的首地址为p,value为值,size为字节数。
void memset(void *start, int value, int size);
- 指针用完后释放内存,将指针赋NULL
delete(p);
p = NULL;
46.C++中的智能指针是什么?
C++程序设计中使用堆内存是非常频繁的操作,堆内存的申请和释放都由程序员自己管理。程序员自己管理堆内存可以提高了程序的效率,但是整体来说堆内存的管理是麻烦的,使用普通指针,容易造成堆内存泄露(忘记释放),二次释放,程序发生异常时内存泄露等问题等,C++11中引入了智能指针的概念,使用智能指针能更好的管理堆内存。
**原理:**智能指针是一种类模板,用来存储指针(指向动态分配对象的指针)。智能指针通过使用引用计数技术来跟踪一个指针被多少个其他指针共享,这样当没有任何指针引用某个对象时,就可以自动释放该对象所占用的内存。
**作用:**它用于自动管理内存,以避免常见的空指针、悬垂指针和内存泄漏问题。还可以提高代码的可读性和可维护性。
47.常用的智能指针及实现
30分钟讲明白现代C++最重要的特性之一:智能指针_哔哩哔哩_bilibili
常用的智能指针
(1) shared_ptr共享指针
实现原理:采用引用计数器的方法,允许多个智能指针指向同一个对象,每当多一个指针指向该对象时,指向该对象的所有智能指针内部的引用计数加1,每当减少一个智能指针指向对象时,引用计数会减1,当计数为0的时候会自动的释放动态分配的资源。
- 智能指针将一个计数器与类指向的对象相关联,引用计数器跟踪共有多少个类对象共享同一指针
- 每次创建类的新对象时,初始化指针并将引用计数置为1
- 当对象作为另一对象的副本而创建时,拷贝构造函数拷贝指针并增加与之相应的引用计数
- 对一个对象进行赋值时,赋值操作符减少左操作数所指对象的引用计数(如果引用计数为减至0,则删除对象),并增加右操作数所指对象的引用计数
- 调用析构函数时,构造函数减少引用计数(如果引用计数减至0,则删除基础对象)
(2) unique_ptr独享指针
unique_ptr采用的是独享所有权语义,一个非空的unique_ptr总是拥有它所指向的资源。转移一个unique_ptr将会把所有权全部从源指针转移给目标指针,源指针被置空;所以unique_ptr不支持普通的拷贝和赋值操作,不能用在STL标准容器中;局部变量的返回值除外(因为编译器知道要返回的对象将要被销毁);如果你拷贝一个unique_ptr,那么拷贝结束后,这两个unique_ptr都会指向相同的资源,造成在结束时对同一内存指针多次释放而导致程序崩溃。
(3) weak_ptr弱指针
weak_ptr:弱引用。 引用计数有一个问题就是互相引用形成环(环形引用),这样两个指针指向的内存都无法释放。需要使用weak_ptr打破环形引用。weak_ptr是一个弱引用,它是为了配合shared_ptr而引入的一种智能指针,它指向一个由shared_ptr管理的对象而不影响所指对象的生命周期,也就是说,它只引用,不计数。如果一块内存被shared_ptr和weak_ptr同时引用,当所有shared_ptr析构了之后,不管还有没有weak_ptr引用该内存,内存也会被释放。所以weak_ptr不保证它指向的内存一定是有效的,在使用之前使用函数lock()检查weak_ptr是否为空指针。
(4) auto_ptr自动指针(已废弃)
主要是为了解决“有异常抛出时发生内存泄漏”的问题 。因为发生异常而无法正常释放内存。
auto_ptr有拷贝语义,拷贝后源对象变得无效,这可能引发很严重的问题;而unique_ptr则无拷贝语义,但提供了移动语义,这样的错误不再可能发生,因为很明显必须使用std::move()进行转移。
auto_ptr不支持拷贝和赋值操作,不能用在STL标准容器中。STL容器中的元素经常要支持拷贝、赋值操作,在这过程中auto_ptr会传递所有权,所以不能在STL中使用。
智能指针shared_ptr代码实现:
template<typename T>
class SharedPtr
{
public:
SharedPtr(T* ptr = NULL):_ptr(ptr), _pcount(new int(1))
{}
SharedPtr(const SharedPtr& s):_ptr(s._ptr), _pcount(s._pcount){
(*_pcount)++;
}
SharedPtr<T>& operator=(const SharedPtr& s){
if (this != &s)
{
if (--(*(this->_pcount)) </font> 0)
{
delete this->_ptr;
delete this->_pcount;
}
_ptr = s._ptr;
_pcount = s._pcount;
*(_pcount)++;
}
return *this;
}
T& operator*()
{
return *(this->_ptr);
}
T* operator->()
{
return this->_ptr;
}
~SharedPtr()
{
--(*(this->_pcount));
if (*(this->_pcount) </font> 0)
{
delete _ptr;
_ptr = NULL;
delete _pcount;
_pcount = NULL;
}
}
private:
T* _ptr;
int* _pcount;//指向引用计数的指针
};
48.使用智能指针管理内存资源,RAII是怎么回事?
RAII(Resource Acquisition Is Initialization资源获取就是初始化)一种使用对象生命周期管理资源的编程技术,与智能指针密切相关。
RAII 的基本思想是,使用一个对象来管理某种资源,这个对象在创建时获取资源,而在销毁时释放资源。由于 C++ 对象的生命周期与作用域紧密相关,因此这种技术能够确保资源在使用后得到正确的释放。
智能指针是一种常用的 RAII 技术的实现方式。使用智能指针可以避免手动管理内存资源的复杂性和风险,同时也能够保证资源在不再需要时得到正确的释放。
当使用智能指针时,可以通过定义一个局部的智能指针对象来获取一个动态分配的对象,这个智能指针对象的析构函数会在离开作用域时自动释放这个动态分配的对象。因此,即使在函数内部发生异常,也能够确保资源得到正确的释放。
毫不夸张的来讲,有了智能指针,代码中几乎不需要再出现delete了。
49.指针占用空间大小
32位编译环境中,指针通常占用4个字节的内存,而在64位编译环境中,指针通常占用8个字节的内存。
一个指针占内存的大小跟编译环境有关,而与机器的位数无关。
50.绝对地址或者访问硬件指定内存下数据
01.关键字、基本函数、预处理中volatile部分以及位操作部分也有,一个道理
1.如何对绝对地址0x100000赋值?
*(unsigned int*)0x100000 = 1234;
2.读硬件寄存器地址数据
1.#define GPA1DAT (*(volatile unsigned int*)0xE0200084)
将 GPA1DAT 宏定义为地址 0xE0200084 上的内容
2.(volatile unsigned int*)0xE0200084
将0xE0200084强制转换为地址int型指针(相当于*p中的p,指的是地址)
3.(*(volatile unsigned int*)0xE0200084)
这句代码则代表地址为0xE0200084上的存放内容(相当于*p,指的是地址上的内容)
4.#define GPA1DAT (*(volatile unsigned int*)0xE0200084)
将地址0xE0200084上的内容定义为GPA1DAT,如果操作GPA1DAT = 1;则地址0x40000000上存放的内容就变成了1,也可以读.
3.那么要是想让程序跳转到绝对地址是0x100000去执行,应该怎么做?(万能函数指针)
*((void (*)( ))0x100000)( ); //((void(*)())p)(); //或者*(void(*)())p()
首先要将0x100000强制转换成函数指针,即:
(void (*)())0x100000
然后再调用它:
*((void (*)())0x100000();
用typedef可以看得更直观些:
typedef void(*)() voidFuncPtr;
*((voidFuncPtr)0x100000)();
或者这样写
void (*func_ptr)() = (void (*)(void)) 0x100000;
func_ptr();
04.C++
51.面向对象的基本特征有哪些?
介绍面向对象的三大特性
三大特性:继承、封装和多态
四个基本特征:
- 封装 (Encapsulation):将数据和相关操作封装在一个对象中,只对外部暴露必要的接口,保证了数据的安全性和完整性。
- 继承 (Inheritance):允许在一个已有类的基础上创建一个新的类,并继承原有类的属性和方法。这样可以避免重复编写代码,同时也方便了代码的维护和修改。
- 多态 (Polymorphism):允许不同的对象对同一消息做出不同的响应,即同一方法可以在不同的对象中有不同的实现。这样可以增强代码的灵活性和可扩展性。
- 抽象 (Abstraction):将具有相似属性和行为的对象抽象成一个类,隐藏不必要的细节,只保留必要的属性和方法。这样可以使代码更加清晰、简洁、易于理解和维护。
52.C++中的重载、重写(覆盖)和隐藏的区别
(1)重载(overload)
重载是指在同一范围定义中的同名成员函数才存在重载关系。主要特点是函数名相同,参数类型和数目有所不同,不能出现参数个数和类型均相同,仅仅依靠返回值不同来区分的函数。重载和函数成员是否是虚函数无关。重载函数可以是成员函数或全局函数。重载可以实现静态多态性(编译时多态)。举个例子:
class A{
...
virtual int fun();
void fun(int);
void fun(double, double);
static int fun(char);
...
}
(2)重写(覆盖)(override)
重写指的是在派生类中覆盖基类中的同名函数,重写就是重写函数体,要求基类函数必须是虚函数且:
- 与基类的虚函数有相同的参数个数
- 与基类的虚函数有相同的参数类型
- 与基类的虚函数有相同的返回值类型
重写只能是成员函数,且必须使用虚函数的关键字来声明。重写可以实现动态多态性(运行时多态)。
举个例子:
//父类
class A{
public:
virtual int fun(int a){}
}
//子类
class B : public A{
public:
//重写,一般加override可以确保是重写父类的函数
virtual int fun(int a) override{}
}
重载与重写(覆盖)的区别:
- 重写是父类和子类之间的垂直关系,重载是同一个类之间不同函数之间的水平关系
- 重写要求参数列表相同,重载则要求参数列表不同,返回值不要求
- 重写关系中,调用方法根据对象类型(对象对应存储空间类型)决定,重载根据调用时实参表与形参表的对应关系来选择函数体
(3)隐藏(hide)
隐藏指的是某些情况下,派生类中的函数屏蔽了基类中的同名函数,隐藏可以是成员函数或全局函数。隐藏不是多态性的一种形式,因为它不会在运行时调用正确的函数。包括以下情况:
- 两个函数参数相同,但是基类函数不是虚函数。**和重写的区别在于基类函数是否是虚函数。**举个例子:
//父类
class A{
public:
void fun(int a){
cout << "A中的fun函数" << endl;
}
};
//子类
class B : public A{
public:
//隐藏父类的fun函数
void fun(int a){
cout << "B中的fun函数" << endl;
}
};
int main(){
B b;
b.fun(2); //调用的是B中的fun函数
b.A::fun(2); //调用A中fun函数
return 0;
}
- 两个函数参数不同,无论基类函数是不是虚函数,都会被隐藏。和重载的区别在于两个函数不在同一个类中。举个例子:
//父类
class A{
public:
virtual void fun(int a){
cout << "A中的fun函数" << endl;
}
};
//子类
class B : public A{
public:
//隐藏父类的fun函数
virtual void fun(char* a){
cout << "A中的fun函数" << endl;
}
};
int main(){
B b;
b.fun(2); //报错,调用的是B中的fun函数,参数类型不对
b.A::fun(2); //调用A中fun函数
return 0;
}
53.C++中类成员的访问权限和继承权限问题
public | ✔ | ✔ | ✔ |
protected | ❌ | ✔ | ✔ |
private | ❌ | ❌ | ✔ |
三种访问权限
public:用该关键字修饰的成员表示公有成员,该成员不仅可以在类内可以被访问,在类外也是可以被访问的,是类对外提供的可访问接口;
protected:用该关键字修饰的成员表示保护成员,保护成员在类体外同样是隐藏状态,但是对于该类的派生类来说,相当于公有成员,在派生类中可以被访问。
private:用该关键字修饰的成员表示私有成员,该成员仅在类内可以被访问,在类体外是隐藏状态;
三种继承方式
派生类对基类成员的访问形式有如下两种:
- 内部访问:由派生类中新增的成员函数对从基类继承来的成员的访问
- 外部访问:在派生类外部,通过派生类的对象对从基类继承来的成员的访问
若继承方式是public,基类成员在派生类中的访问权限保持不变,基类中的成员访问权限,在派生类中仍然保持原来的访问权限;
若继承方式是private,基类所有成员在派生类中的访问权限都会变为私有(private)权限;
若继承方式是protected,基类的共有成员和保护成员在派生类中的访问权限都会变为保护(protected)权限,私有成员在派生类中的访问权限仍然是私有(private)权限。
54.C++有哪几种构造函数
-
默认构造函数
没有任何参数的构造函数被称为默认构造函数。如果没有定义构造函数,则编译器会自动提供默认构造函数。默认构造函数可以用来创建对象,但是不能传递任何参数。
#include <iostream>
class MyClass {
public:
// 默认构造函数
MyClass() {
std::cout << "Default constructor called." << std::endl;
}
};
int main() {
// 使用默认构造函数创建对象
MyClass obj; // 输出: "Default constructor called."
return 0;
}
-
有参构造函数(初始化构造函数)
带有一个或多个参数的构造函数被称为带参数的构造函数。带参数的构造函数可以用来初始化对象的成员变量,可以接受一个或多个参数。
class MyClass {
public:
MyClass(int a, int b) {
// 这里可以对成员变量进行初始化,使用参数a和b
}
};
#include <iostream>
class MyClass {
public:
// 带参数的构造函数
MyClass(int value) : data(value) {
std::cout << "Constructor with parameter called. Value: " << data << std::endl;
}
private:
int data;
};
int main() {
// 使用带参数的构造函数创建对象
MyClass obj(42); // 输出: "Constructor with parameter called. Value: 42"
return 0;
}
-
拷贝构造函数
用于从一个已经存在的对象中创建一个新的对象的构造函数被称为拷贝构造函数。拷贝构造函数接受一个参数,这个参数是同类型的另一个对象的引用。它通常用于在函数参数传递或返回对象时,或者在对象赋值时进行对象的拷贝。
class MyClass {
public:
MyClass(const MyClass& other) {
// 这里可以从另一个同类型的对象other中拷贝成员变量的值
}
};
#include <iostream>
class MyClass {
public:
// 拷贝构造函数
MyClass(const MyClass& other) : data(other.data) {
std::cout << "Copy constructor called. Value: " << data << std::endl;
}
// 带参数的构造函数
MyClass(int value) : data(value) {}
private:
int data;
};
int main() {
// 使用拷贝构造函数创建对象
MyClass obj1(42);
MyClass obj2 = obj1; // 输出: "Copy constructor called. Value: 42"
return 0;
}
-
移动构造函数
用于从一个已经存在的临时对象中创建一个新的对象的构造函数被称为移动构造函数。它通常用于在对象的值被转移(比如将一个临时对象转移给一个新对象)时,避免不必要的拷贝操作,从而提高代码的性能。
class MyClass {
public:
MyClass(MyClass&& other) {
// 这里可以从另一个同类型的临时对象other中移动成员变量的值
}
};
#include <iostream>
class MyClass {
public:
// 移动构造函数
MyClass(MyClass&& other) noexcept : data(other.data) {
std::cout << "Move constructor called. Value: " << data << std::endl;
}
// 带参数的构造函数
MyClass(int value) : data(value) {}
private:
int data;
};
int main() {
// 使用移动构造函数创建对象
MyClass obj1(42);
MyClass obj2 = std::move(obj1); // 输出: "Move constructor called. Value: 42"
return 0;
}
- 转换构造函数 一个构造函数接收一个不同于其类类型的形参,可以视为将其形参转换成类的一个对象。像这样的构造函数称为转换构造函数。在 C++ string 类中可以找到使用转换构造函数的实用示例。string 类提供一个将 C 字符串转换为 string 的转换构造函数
class string
{
//仅显示转换构造函数
public:
string(char *);//形参时其他类型变量,且只有一个形参
};
55.构造函数、拷贝构造函数和赋值操作符的区别
构造函数
对象不存在,没用别的对象初始化,在创建一个新的对象时调用构造函数
拷贝构造函数
对象不存在,但是使用别的已经存在的对象来进行初始化
赋值运算符
对象存在,用别的对象给它赋值,这属于重载“=”号运算符的范畴,“=”号两侧的对象都是已存在的
以下是C++中构造函数、拷贝构造函数和赋值操作符的代码示意:
- 构造函数:
class MyClass {
public:
// 默认构造函数
MyClass() {
// 初始化代码
}
// 带参数的构造函数
MyClass(int value) {
// 初始化代码
}
};
// 创建对象
MyClass obj1; // 调用默认构造函数
MyClass obj2(10); // 调用带参数的构造函数
- 拷贝构造函数:
class MyClass {
public:
// 拷贝构造函数
MyClass(const MyClass& other) {
// 复制值的代码
}
};
// 创建对象
MyClass obj1;
MyClass obj2 = obj1; // 调用拷贝构造函数
- 赋值操作符:
class MyClass {
public:
// 赋值操作符
MyClass& operator=(const MyClass& other) {
// 分配新值的代码
return *this;
}
};
// 创建对象
MyClass obj1;
MyClass obj2;
obj2 = obj1; // 调用赋值操作符
56.为什么一个类作为基类被继承,其析构函数必须是虚函数?
在 C++ 中,如果一个类定义了带有虚函数的成员函数,就意味着这个类具有多态性。在多态性中,一个指针或引用可以指向多种不同类型的对象,而程序会根据指针或引用所指向的对象的类型来调用相应的成员函数。
在继承关系中,基类指针可以指向派生类对象,这是因为派生类对象包含了基类对象的所有成员,因此可以视为基类对象的一种扩展。如果一个基类的析构函数不是虚函数,在基类指针指向派生类对象时,如果使用 delete
运算符释放该对象,只会调用基类的析构函数,而不会调用派生类的析构函数。这样就会导致派生类对象中的资源没有被正确释放,从而造成内存泄漏。
因此,如果一个类作为基类被继承,其析构函数应该声明为虚函数,这样在派生类对象被销毁时,派生类的析构函数也会被自动调用。这样就能确保基类指针指向派生类对象时,调用 delete
运算符能够正确地释放对象中的资源,从而避免内存泄漏的发生。
示例:
#include <iostream>
using namespace std;
class Parent{
public:
Parent(){
cout << "Parent construct function" << endl;
};
~Parent(){ //非虚析构
cout << "Parent destructor function" <<endl;
}
};
class Son : public Parent{
public:
Son(){
cout << "Son construct function" << endl;
};
~Son(){
cout << "Son destructor function" <<endl;
}
};
int main()
{
Parent* p = new Son();
delete p;
p = NULL;
return 0;
}
//运行结果:
//Parent construct function
//Son construct function
//Parent destructor function 只释放父类
将基类的析构函数声明为虚函数:
#include <iostream>
using namespace std;
class Parent{
public:
Parent(){
cout << "Parent construct function" << endl;
};
virtual ~Parent(){ //虚析构
cout << "Parent destructor function" <<endl;
}
};
class Son : public Parent{
public:
Son(){
cout << "Son construct function" << endl;
};
~Son(){
cout << "Son destructor function" <<endl;
}
};
int main()
{
Parent* p = new Son();
delete p;
p = NULL;
return 0;
}
//运行结果:
//Parent construct function
//Son construct function
//Son destructor function //子类先释放
//Parent destructor function //父类再释放
CRTP模板看模板部分
但存在一种特例,在CRTP
模板中,不应该将析构函数声明为虚函数,理论上所有的父类函数都不应该声明为虚函数,因为这种继承方式,不需要虚函数表。
在CRTP模板中,使用的是静态多态性而非动态多态性。因此,基类不需要将其析构函数声明为虚函数,因为在CRTP中,子类是通过模板实例化而非运行时生成的。这意味着,在运行时不存在基类指针指向派生类对象的情况,因此不需要虚函数表来实现动态绑定。
在CRTP中,基类和派生类之间的关系是通过模板参数来实现的,而非通过继承来实现的。因此,基类的析构函数只会在编译时被调用,并且不需要在运行时动态绑定。基类的析构函数是由派生类的析构函数调用的,而不是通过基类指针调用。
因此,在CRTP模板中,不应该将析构函数声明为虚函数,这会增加虚函数表的开销,同时也会使程序更难以维护。在这种情况下,可以通过非虚析构函数来确保在销毁派生类对象时正确地释放所有资源。
57.构造函数一般不定义为虚函数的原因
构造函数一般不定义为虚函数的原因是因为在对象创建时,虚函数机制还没有建立起来,因此无法实现动态绑定。当我们调用一个虚函数时,需要通过虚函数表和虚函数指针来实现动态绑定,但在对象的构造函数被调用时,对象还没有完全创建,虚函数表和虚函数指针也还没有建立。因此,将构造函数定义为虚函数是没有意义的。
另外,C++中的构造函数是用来初始化对象的,而不是用来实现多态的。虚函数的作用在于通过父类的指针或者引用调用它的时候能够变成调用子类的那个成员函数。而构造函数是在创建对象时自动调用的,不可能通过父类或者引用去调用,因此就规定构造函数不能是虚函数。
因此,一般情况下,构造函数不会定义为虚函数。但是,在某些特殊的情况下,需要使用虚构造函数来实现某些特定的需求,比如工厂模式等。但是这样的情况相对较少,一般情况下不需要将构造函数定义为虚函数。
58.构造函数、析构函数的执行顺序?构造函数和拷贝构造的内部都干了啥?
构造函数顺序:
- 基类的构造函数(如果存在)
- 成员变量的构造函数(按照它们在类中声明的顺序初始化)
- 派生类的构造函数
在对象的销毁过程中,析构函数的执行顺序正好相反:
- 派生类的析构函数
- 成员变量的析构函数(按照它们在类中声明的相反顺序销毁)
- 基类的析构函数(如果存在)
对于拷贝构造函数,它的内部一般会进行以下操作:
- 拷贝对象中的成员变量,将其值复制到新对象中。
- 如果对象中存在指针成员,需要为它们分配新的内存并将指针指向新的内存地址。
- 如果对象中存在引用成员,则将引用直接绑定到新对象上。
59.浅拷贝和深拷贝的区别
浅拷贝
将一个对象的值复制到另一个对象中,但是如果这个对象中有指向动态分配内存的指针,则只是复制指针的地址,而不是复制指针指向的内存空间。拷贝的指针和原来的指针指向同一块地址,如果原来的指针所指向的资源释放了,那么再释放浅拷贝的指针的资源就会出现错误。浅拷贝通常使用默认的拷贝构造函数和赋值运算符来实现。
深拷贝
将一个对象的值复制到另一个对象中,并且如果这个对象中有指向动态分配内存的指针,则会在另一个对象中重新分配一段内存空间,并将原来指针指向的内存空间中的内容复制到新的内存空间中。这样,即使一个对象的值发生变化,另一个对象也不会受到影响。深拷贝通常需要自定义拷贝构造函数和赋值运算符来实现。
#include <iostream>
#include <string.h>
using namespace std;
class Student
{
private:
int num;
char *name;
public:
Student(){
name = new char(20);
cout << "Student" << endl;
};
~Student(){
cout << "~Student " << &name << endl;
delete name;
name = NULL;
};
Student(const Student &s){//拷贝构造函数
//浅拷贝,当对象的name和传入对象的name指向相同的地址
name = s.name;
//深拷贝
//name = new char(20);
//memcpy(name, s.name, strlen(s.name));
cout << "copy Student" << endl;
};
};
int main()
{
{// 花括号让s1和s2变成局部对象,方便测试
Student s1;
Student s2(s1);// 复制对象
}
system("pause");
return 0;
}
//浅拷贝执行结果:
//Student
//copy Student
//~Student 0x7fffed0c3ec0
//~Student 0x7fffed0c3ed0
//*** Error in `/tmp/815453382/a.out': double free or corruption (fasttop): 0x0000000001c82c20 ***
//深拷贝执行结果:
//Student
//copy Student
//~Student 0x7fffebca9fb0
//~Student 0x7fffebca9fc0
从执行结果可以看出,浅拷贝在对象的拷贝创建时存在风险,即被拷贝的对象析构释放资源之后,拷贝对象析构时会再次释放一个已经释放的资源,深拷贝的结果是两个对象之间没有任何关系,各自成员地址不同。
总之,浅拷贝只是复制了对象的地址,而不是对象的内容,而深拷贝则是复制了对象的内容,包括指针所指向的内容。因此,在需要复制指向动态分配内存的指针的情况下,深拷贝是更安全和可靠的选择(如果属性有在堆区开辟的,一定要自己提供拷贝构造函数,防止浅拷贝带来的问题。)。
60.成员初始化列,为什么用它会快一些?
在C++中,我们可以使用两种方式来初始化类的成员变量:构造函数中初始化和成员初始化列表。
构造函数中初始化是通过在构造函数的函数体内赋值来实现的。例如:
class MyClass {
public:
MyClass(int a, int b) {
m_a = a;
m_b = b;
}
private:
int m_a;
int m_b;
};
而成员初始化列表是通过在构造函数的参数列表后面用冒号分隔,然后列出成员变量名和它们的初始值来实现的。例如:
class MyClass {
public:
MyClass(int a, int b) : m_a(a), m_b(b) {}
private:
int m_a;
int m_b;
};
使用成员初始化列表会更快一些的原因是因为在构造函数中初始化时,编译器会先调用默认构造函数来初始化成员变量,然后再将初始值赋给它们。这意味着在构造函数体内的赋值语句中,每个成员变量实际上被初始化了两次。而使用成员初始化列表时,成员变量被直接初始化为所需的值,这样就避免了不必要的初始化和赋值操作,从而提高了效率。此外,成员初始化列表还可以初始化const成员变量和引用类型成员变量,而构造函数体内则不能初始化这些成员变量。
61.有哪些情况必须用到成员列表初始化?作用是什么?
必须使用成员初始化的四种情况
-
当初始化一个常量成员时;
在C++中,const成员变量必须在创建对象时进行初始化,并且只能通过成员列表初始化来完成。这是因为const成员变量无法在构造函数体内修改。例如:
class MyClass {
public:
MyClass(int arg) : const_member(arg) {
// constructor body
}
private:
const int const_member;
};
-
当初始化一个引用成员时;
在C++中,引用成员变量必须在创建对象时进行初始化,并且只能通过成员列表初始化来完成。这是因为引用成员变量在创建对象时必须引用一个已经存在的对象。例如:
class MyClass {
public:
MyClass(int& arg) : ref_member(arg) {
// constructor body
}
private:
int& ref_member;
};
-
当调用一个基类的构造函数,而它拥有一组参数时;
如果一个类是派生自另一个类,那么在构造函数中必须使用成员列表初始化基类成员变量。例如:
class MyBase {
public:
MyBase(int arg) : base_member(arg) {
// constructor body
}
private:
int base_member;
};
class MyClass : public MyBase {
public:
MyClass(int arg1, double arg2) : MyBase(arg1), class_member(arg2) {
// constructor body
}
private:
double class_member;
};
-
当调用一个成员类的构造函数,而它拥有一组参数时;
如果类类型的成员变量没有默认构造函数或者希望使用不同的构造函数进行初始化,那么必须使用成员列表初始化。例如:
在这个例子中,MyClass派生自MyBase,所以MyClass的构造函数必须调用MyBase的构造函数来初始化基类成员变量base_member。
class MyClass {
public:
MyClass(int arg) : class_member(arg) {
// constructor body
}
private:
AnotherClass class_member;
};
成员列表初始化可以提高代码的可读性和性能。在构造函数体内部初始化成员变量,会先调用成员变量的默认构造函数,然后再进行赋值。而成员列表初始化可以直接调用成员变量的构造函数,避免了不必要的性能开销。
62.this指针的作用
this指针的使用
this 指针主要用于在类的成员函数中访问当前对象的成员变量和成员函数,以及返回当前对象的引用。以下是几个使用 this 指针的示例:
- 访问成员变量:使用 this 指针可以方便地访问当前对象的成员变量。例如:
class MyClass {
public:
void setX(int x) { this->x_ = x; }
int getX() const { return this->x_; }
private:
int x_;
};
在上面的代码中,setX() 和 getX() 成员函数都使用 this 指针来访问 x_ 成员变量。
- 访问成员函数:使用 this 指针可以方便地调用当前对象的其他成员函数。例如:
class MyClass {
public:
void foo() {
// 调用成员函数 bar()
this->bar();
}
void bar() { /* ... */ }
};
在上面的代码中,foo() 成员函数中调用了 bar() 成员函数,使用 this 指针来调用当前对象的其他成员函数。
- 返回当前对象的引用:使用 this 指针可以方便地返回当前对象的引用,这在链式调用中非常有用。例如:
class MyClass {
public:
MyClass& setX(int x) {
this->x_ = x;
return *this;
}
private:
int x_;
};
在上面的代码中,setX() 成员函数返回当前对象的引用,这样就可以实现链式调用。
- 区分同名的局部变量和成员变量:如果在成员函数中存在一个局部变量和一个同名的成员变量,可以使用 this 指针来访问成员变量,以避免混淆。例如:
class MyClass {
public:
void setX(int x) {
// 使用 this 指针来访问成员变量 x_
this->x_ = x;
int x = 0; // 局部变量 x
}
private:
int x_;
};
在上面的代码中,使用 this 指针来访问成员变量 x_,以避免与局部变量 x 混淆。
63.C++ 多态的底层实现原理
C++如何实现多态?
虚函数表具体是怎样实现运行时多态的?
虚表指针vptr的初始化时间
十分钟带你搞明白虚函数、虚表、多态的原理以及多重继承带来的问题_哔哩哔哩_bilibili
C++多态的底层原理_卖寂寞的小男孩的博客-CSDN博客_c++ 多态的本质回调
C++中通过虚函数实现多态。虚函数的本质就是通过基类指针访问派生类定义的函数。每个含有虚函数的类,其实例对象内部都有一个虚函数表指针。该虚函数表指针被初始化为本类的虚函数表的内存地址。所以,在程序中,不管对象类型如何转换,该对象内部的虚函数表指针都是固定的,这样才能实现动态地对对象函数进行调用,这就是C++多态性的原理。
虚表:虚函数表的缩写,类中含有virtual关键字修饰的方法时,编译器会自动生成虚表,是一个存储类成员函数指针的数据结构。
虚表指针:在含有虚函数的类实例化对象时,对象地址的前四个字节存储的指向虚表的指针
上图中展示了虚表和虚表指针在基类对象和派生类对象中的模型,下面阐述实现多态的过程:
-
编译器在发现基类中有虚函数时,会自动为每个含有虚函数的类生成一份虚表,该表是一个一维数组,虚表里保存了虚函数的入口地址
-
编译器会在每个对象的前四个字节中保存一个虚表指针,即 *vptr,指向对象所属类的虚表。在构造时,根据对象的类型去初始化虚指针vptr,从而让vptr指向正确的虚表,从而在调用虚函数时,能找到正确的函数
-
在派生类定义对象时,程序运行会自动调用构造函数,在构造函数中创建虚表并对虚表初始化。在构造子类对象时,会先调用父类的构造函数,此时,编译器只“看到了”父类,并为父类对象初始化虚表指针,令它指向父类的虚表;当调用子类的构造函数时,为子类对象初始化虚表指针,令它指向子类的虚表
-
当派生类对基类的虚函数没有重写时,派生类的虚表指针指向的是基类的虚表;当派生类对基类的虚函数重写时,派生类的虚表指针指向的是自身的虚表;当派生类中有自己的虚函数时,在自己的虚表中将此虚函数地址添加在后面。
这样指向派生类的基类指针在运行时,就可以根据派生类对虚函数重写情况动态的进行调用,从而实现多态性。
64.基类的虚函数表存放在内存的什么区
虚函数表的特征:
- 虚函数表是全局共享的元素,即全局仅有一个,在编译时就构造完成
- 虚函数表类似一个数组,类对象中存储vptr指针,指向虚函数表,即虚函数表不是函数,不是程序代码,不可能存储在代码段
- 虚函数表存储虚函数的地址,即虚函数表的元素是指向类成员函数的指针,而类中虚函数的个数在编译时期可以确定,即虚函数表的大小可以确定,即大小是在编译时期确定的,不必动态分配内存空间存储虚函数表,所以不在堆中
C++中虚函数表位于只读数据段(.rodata),也就是C++内存模型中的常量区;而虚函数则位于代码段(.text),也就是C++内存模型中的代码区。
虚函数表属于常量数据,它是在编译时就生成的,且在程序运行期间不会被修改,因此通常会被放在只读数据段(.rodata)中。
虚函数则属于可执行代码,它是程序的一部分,需要在运行期间被执行。因此,虚函数通常会被放在代码段(.text)中。虽然代码段是可执行的,但是对于虚函数来说,由于其代码在程序运行期间不会被修改,因此通常也被视为常量数据。
65.什么是虚函数?
虚函数的作用
虚函数是指可以被子类覆盖的成员函数。当使用一个基类指针或引用来调用一个虚函数时,程序会根据实际对象类型来选择调用哪个函数,这被称为运行时多态。
66.哪些函数不能是虚函数?
- 构造函数,构造函数初始化对象,派生类必须知道基类函数干了什么,才能进行构造;当有虚函数时,每一个类有一个虚表,每一个对象有一个虚表指针,虚表指针在构造函数中初始化;
- 内联函数,内联函数表示在编译阶段进行函数体的替换操作,而虚函数意味着在运行期间进行类型确定,所以内联函数不能是虚函数;
- 静态函数,静态函数不属于对象属于类,静态成员函数没有this指针,因此静态函数设置为虚函数没有任何意义。
- 友元函数,友元函数不属于类的成员函数,不能被继承。对于没有继承特性的函数没有虚函数的说法。
- 普通函数,普通函数不属于类的成员函数,不具有继承特性,因此普通函数没有虚函数。
67.什么是纯虚函数,与虚函数的区别
虚函数和纯虚函数区别?
-
虚函数是为了实现动态编联产生的,目的是通过基类类型的指针指向不同对象时,自动调用相应的、和基类同名的函数(使用同一种调用形式,既能调用派生类又能调用基类的同名函数)。虚函数需要在基类中加上virtual修饰符修饰,因为virtual会被隐式继承,所以子类中相同函数都是虚函数。当一个成员函数被声明为虚函数之后,其派生类中同名函数自动成为虚函数,在派生类中重新定义此函数时要求函数名、返回值类型、参数个数和类型全部与基类函数相同。
-
纯虚函数只是相当于一个接口名,含有纯虚函数的类称为抽象类,抽象类不能够实例化。
纯虚函数首先是虚函数,其次它没有函数体,取而代之的是用“=0”。
既然是虚函数,它的函数指针会被存在虚函数表中,由于纯虚函数并没有具体的函数体,因此它在虚函数表中的值就为0,而具有函数体的虚函数则是函数的具体地址。
一个类中如果有纯虚函数的话,称其为抽象类。抽象类不能用于实例化对象,否则会报错。抽象类一般用于定义一些公有的方法。子类继承抽象类也必须实现其中的纯虚函数才能实例化对象。
举个例子:
#include <iostream>
using namespace std;
class Base
{
public:
virtual void fun1()
{
cout << "普通虚函数" << endl;
}
virtual void fun2() = 0;
virtual ~Base() {}
};
class Son : public Base
{
public:
virtual void fun2()
{
cout << "子类实现的纯虚函数" << endl;
}
};
int main()
{
Base* b = new Son;
b->fun1(); //普通虚函数
b->fun2(); //子类实现的纯虚函数
return 0;
}
68.常见容器性质总结?
-
vector 底层数据结构为数组 ,支持快速随机访问
-
list底层数据结构为双向链表,支持快速增删
-
deque 底层数据结构为一个中央控制器和多个缓冲区,支持首尾(中间不能)快速增删,也支持随机访问
deque是一个双端队列,也是在堆中保存内容的.
-
stack 底层一般用list或deque实现,封闭头部即可,不用vector的原因应该是容量大小有限制,扩容耗时
-
queue 底层一般用list或deque实现,封闭头部即可,不用vector的原因应该是容量大小有限制,扩容耗时(stack和queue其实是适配器,而不叫容器,因为是对容器的再封装)
-
priority_queue 的底层数据结构一般为vector为底层容器,堆heap为处理规则来管理底层容器实现
-
set 底层数据结构为红黑树,有序,不重复
-
multiset 底层数据结构为红黑树,有序,可重复
-
map 底层数据结构为红黑树,有序,不重复
-
multimap 底层数据结构为红黑树,有序,可重复
-
unordered_set 底层数据结构为hash表,无序,不重复
-
unordered_multiset 底层数据结构为hash表,无序,可重复
-
unordered_map 底层数据结构为hash表,无序,不重复
-
unordered_multimap 底层数据结构为hash表,无序,可重复
69.C++ 11有哪些新特性?
- 智能指针:引入了unique_ptr和shared_ptr等智能指针,可以帮助程序员更加方便和安全地管理内存。
- 右值引用:引入了新的引用类型,可以绑定到右值(临时对象、返回值等)。
- Lambda表达式:一种简洁的方式来定义匿名函数,可以捕获外部变量,使得代码更加简洁。
- nullptr关键字:可以用来表示空指针,避免了NULL常量的一些问题。
- 自动类型推导(auto):可以让编译器根据变量初始化的表达式自动推导出变量的类型。
- 静态断言(static_assert):可以在编译时进行断言,帮助程序员发现一些潜在的错误。
- 类型别名(type alias):可以使用using关键字定义类型别名,使得类型名更加易读易写。
- range-based for循环:一种更简洁的循环语法,可以遍历一个区间中的所有元素。
- constexpr关键字:可以让函数或变量在编译时求值,提高程序性能。
- 并发编程支持:引入了线程库和原子操作等特性,方便程序员进行并发编程。
记忆:右能Lna
70.C++左值引用和右值引用
为什么C/C++等少数编程语言要区分左右值? - 知乎 (zhihu.com)
C++新标准001_“左左右右分不清”右值引用_哔哩哔哩_bilibili
C++11正是通过引入右值引用来优化性能,具体来说是通过移动语义来避免无谓拷贝的问题,通过move语义来将临时生成的左值中的资源无代价的转移到另外一个对象中去,通过完美转发来解决不能按照参数实际类型来转发的问题(同时,完美转发获得的一个好处是可以实现移动语义)。
左值和右值
左值:表示的是可以获取地址的表达式,它能出现在赋值语句的左边,对该表达式进行赋值。但是修饰符const的出现使得可以声明如下的标识符,它可以取得地址,但是没办法对其进行赋值
const int& a = 10;
右值:表示无法获取地址的对象,有常量值、函数返回值、lambda表达式等。无法获取地址,但不表示其不可改变,当定义了右值的右值引用时就可以更改右值。
左值引用和右值引用
左值引用:传统的C++中引用被称为左值引用
右值引用:C++11中增加了右值引用,右值引用关联到右值时,右值被存储到特定位置,右值引用指向该特定位置,也就是说,右值虽然无法获取地址,但是右值引用是可以获取地址的,该地址表示临时对象的存储位置
这里主要说一下右值引用的特点:
- 特点1:通过右值引用的声明,右值又“重获新生”,其生命周期与右值引用类型变量的生命周期一样长,只要该变量还活着,该右值临时量将会一直存活下去
- 特点2:右值引用独立于左值和右值。意思是右值引用类型的变量可能是左值也可能是右值
- 特点3:T&& t在发生自动类型推断的时候,它是左值还是右值取决于它的初始化。
举个例子:
#include <bits/stdc++.h>
using namespace std;
template<typename T>
void fun(T&& t)
{
cout << t << endl;
}
int getInt()
{
return 5;
}
int main() {
int a = 10;
int& b = a; //b是左值引用
int& c = 10; //错误,c是左值不能使用右值初始化
int&& d = 10; //正确,右值引用用右值初始化
int&& e = a; //错误,e是右值引用不能使用左值初始化
const int& f = a; //正确,左值常引用相当于是万能型,可以用左值或者右值初始化
const int& g = 10; //正确,左值常引用相当于是万能型,可以用左值或者右值初始化
const int&& h = 10; //正确,右值常引用
const int& aa = h; //正确
int& i = getInt(); //错误,i是左值引用不能使用临时变量(右值)初始化
int&& j = getInt(); //正确,函数返回值是右值
fun(10); //此时fun函数的参数t是右值
fun(a); //此时fun函数的参数t是左值
return 0;
}
71.C++中的Lambda表达式?
清晰易懂,现代C++最好用特性之一:Lambda表达式用法详解_哔哩哔哩_bilibili
C++中的Lambda表达式是一种匿名函数/闭包,它可以在需要函数的地方使用,而无需显式地定义一个命名函数。Lambda表达式提供了一种更方便和灵活的方式来编写简短的函数,尤其是用于函数对象、算法和函数式编程的场景。
下面是一个基本的Lambda表达式的语法结构:
[capture list](parameter list) -> return type {
// 函数体
}
-
capture list
(捕获列表):用于指定Lambda表达式中使用的外部变量。可以通过值捕获或引用捕获方式来捕获变量。例如,[x]
表示按值捕获变量x
,[&y]
表示按引用捕获变量y
。还可以使用捕获初始化器来指定初始值,例如[x = 42]
表示按值捕获变量x
并将其初始化为42。 -
parameter list
(参数列表):Lambda函数的参数列表,类似于普通函数的参数列表。参数可以省略类型,编译器可以进行类型推导。 -
return type
(返回类型):Lambda函数的返回类型。可以省略返回类型,编译器可以根据函数体中的表达式进行推导。 -
{}
(函数体):包含Lambda函数的具体实现。
以下是一个示例,展示了Lambda表达式的用法:
#include <iostream>
int main() {
int x = 5;
int y = 10;
// Lambda表达式示例:将两个数相加并输出结果
auto sum = [x, &y]() -> int {
return x + y;
};
std::cout << "Sum: " << sum() << std::endl;
return 0;
}
在上面的示例中,Lambda表达式使用捕获列表 [x, &y]
捕获了变量 x
(按值捕获)和 y
(按引用捕获)。Lambda函数的返回类型通过 -> int
指定为 int
类型,然后在函数体中计算了 x + y
的和并返回。
注意,Lambda表达式可以在需要函数的地方使用,例如可以将其传递给STL算法函数、作为函数对象使用等。Lambda表达式提供了一种便捷的方式来编写短小的、临时的函数代码,从而增加了代码的可读性和灵活性。
#嵌入式##八股#【嵌入式八股】精华版