C++基本特性

249 阅读14分钟

1、指针与引用

1.1 变量与指针地址

变量表示一块内存区域、变量值被设置在这块区域内,变量拥有自己的名字变量名用于标识变量存储的内存空间。 例如,当我们声明一个int型变量x时,x是变量名,它的内存地址是0x0000。在程序中使用变量x时经过两个步骤:(1)通过符号表找出与变量x相对应的内存地址。(2)根据内存地址取出该地址对应的内存值进行操作。

1.2 指针

指针一般是指针变量,指针变量存储的是其指向的对象的首地址。按照指向对象的类型划分,指针可以指向变量、数组、函数等具有内存地址的实体,但不同类型的指针变量所占用的内存空间大小是相同的,在32位系统下为4Byte。由于指针变量自身是变量,因此它的变量值是可以改变的,这使得指针变量可以更换指向的对象。

指针是C/C++语言特有的内存操作机制,C/C++程序员可以使用指针对变量内存地址进行动态内存分配等灵活性操作,这也给予了C/C++程序员极大的内存操作自由。

1.2.1 变量指针

如代码所示,定义int型变量x与int型指针ptr,使用连字号(&)运算符访问变量x的内存地址并赋值给ptr。同理,对于结构体类型同样适用。

int x = 0;
int *ptr = &x;  // int类型指针ptr的值为变量x的内存地址
printf("x=%d", *ptr); // 通过使用运算符 * 来返回指针所指定地址的变量值
struct student;
student stu;
student *ptr = &stu;  // 自定义结构体类型指针ptr的值为变量stu的内存地址

1.2.2 数组名与指针

在C/C++中数组名就是数组的首元素的地址、数组名与指针颇为相似,因此可以为取数组名赋一个指针,这样就可以通过指针访问数组元素

int array[6] = {1,2,3,4,5,6};
int *ptr = array;
printf("array[0]=%d", *ptr); // ptr为数组的首地址,使用*操作符将返回数组的第一个元素值
// 指针支持++、--、+、-四种运算
printf("array[1]=%d", *(ptr++)); // *(ptr++)将返回数组第二个元素值,此时ptr也指向了数组的第二个元素地址

注意:数组名与指针较为相似,但也有不同之处:

  • (1)数组名是指针常量,指针是指针变量;
  • (2)使用sizeof计算变量占用地址空间大小时,对数组名使用sizeof得到整个数组素有元素占用的字节,而对指针sizeof则得到指针变量占用的字节数(32位系统下为4字节)。同理,使用&操作数组名和指针时意义也不同,此时数组名也代表了数组的整体,而非普通的常量指针。

1.2.3 函数指针

众所周知,程序中定义的函数在编译时也会为函数分配存储空间,函数名为这段空间的首地址,因此我们可以定义一个指针来存储函数首地址,即函数指针。与变量指针定义不尽相同的是,函数指针的定义需要声明函数的返回值和参数列表:函数返回值类型 (* 指针变量名) (函数参数列表)。

int func(int x);   // 声明一个函数
int (*ptr) (int x);  // 定义一个函数指针, 声明返回值为int类型,参数为int类型
ptr = Func;          // 将func函数的首地址赋给指针变量ptr

// 调用
int x = 10;
int y = (*ptr)(x);  // 通过函数指针调用func函数

1.2.4 指针作为函数返回值/参数

众所周知,函数在调用和返回过程中存在形参到实参的拷贝和返回值拷贝,且函数在压内存栈的过程中实参和返回值会压入栈中,因此如果如果函数参数和返回值占用内存较大,会导致内存拷贝效率低并耗费大量栈内存。为了避免这一问题,C++允许将指针作为参数进行函数调用,指针变量占用内存大小是固定的,这极大的提高了函数调用的计算效率和内存消耗。

// 使用int类型指针作为参数
void func(int* ptr);
struct student;
// 使用结构体指针作为参数
void func(student* ptr);
// 使用结构体指针作为参数和返回值
// struct* func(student* ptr);

1.3 引用

引用可以理解为变量的别名,实际上引用是一种常量指针,引用在被创建时初始化指向一个变量,在使用引用时自动调用*操作符取得变量值。

int x;  // 声明int型变量x
int &ref = x; // 使用&声明变量x的引用ref

1.3.1 引用作为函数参数/返回值

与指针类似的,C++支持将引用作为函数参数和返回值调用。

// 使用int类型指针作为参数
void func(int& ptr);
struct student;
// 使用结构体指针作为参数
void func(student& ptr);
// 使用结构体指针作为参数和返回值
// struct& func(student& ptr);

1.3.2 函数参数的值传递和地址传递

函数在调用过程中存在形参到实参的拷贝,因此如果我们不使用指针或引用作为函数的参数,那么函数在执行过程中对形参的操作均是实参的拷贝,而实参变量并未参与函数的计算。

// 值传递
void add(int x, int y);
// 地址传递
void add(int *x, int *y); // 指针
void add(int &x, int &y); // 引用

1.3.3 引用与数组

在介绍指针是说到数组名就是数组首元素的地址,对数组名+1其实就是加上指针的大小,但是引用是怎么回事?

实际上:&arr就是数组的地址,对而不是首元素地址。对&arr加1就是对&arr就是加上整个数组的大小

1.4 面试考点

1.4.1 指针与引用的区别是什么?

  • 存在形式:指针是一个变量,有自己的空间。引用只不过是一个别名,没有字节的空间。因此在使用sizeof获取指针和引用大小时,指针的大小为4(32位),而引用返回的是引用对象的内存空间的大小
  • 初始化时:指针可以被初始化为NULL,而引用必须初始化关联一个对象
  • 初始化时:指针可以被修改为指向其他指针,但是引用不可修改所引用的对象
  • 使用操作时:指针需要被解引用才可以进行变量的操作指针对于++、--、+=、-=操作符具有特殊的意义;而对引用的操作就是直接修改所引用的变量的值。

1.4.2 指针操作对象的方式?指针作为函数参数时的地址传递介绍

  • 指针通过解引用操作符*对其指向的对象进行操作
  • 指针作为函数参数传递后,在函数中操作指针类型的形参进行对象的操作,也会改变实参对象;实际上,通过指针操作的是同一个对象,同一片内存空间。

1.4.3 指针和引用的算数运算

  • 指针的值是变量地址。因此,指针可以进行进行四种算术运算:++、--、+、-。并且,指针进行算数运算与指针指向变量的类型关联,若指针指向的是int型(4字节)的变量,则该指针进行算数运算都会偏移4的倍数个字节。例如有一个指针ptr指向内存地址为1000,指针为指向int型变量的指针,则ptr++运算后该指针会指向1004的位置。
  • 引用的算数运算即是引用对象的算数运算。

2、const 与 static

const实现了常量语义,使得编译器强制对const修饰的变量约束不可修改。若在程序设计中某个变量的值是保持不变的,那么程序应该明确使用const来强制约束。

2.1 const 常量

2.1.1const修饰普通变量

const int x = 0; // const修饰变量x为常量
x = 1; // 错误,x不可修改
int y = x; // 正确

变量x被const修饰为常量,x的值不可以被修改,但当尝试使用指针来修改变量x时:

const int x = 0; // const修饰变量x为常量
int *ptr = (int*)&x;
(*ptr) = 1;
cout<<x<<endl;  // 输出0
cout<<*ptr<<endl;  // 输出1

这段代码可以编译通过,但运行时我们会发现输出指针ptr指向的地址空间值是1,但输出变量x的值仍是0。这与编译器的优化有关,编译器发现定义变量x与输出变量x的代码之间没有对变量x进行修改,那么在输出x的值时候编译器会从寄存器中读取上次读变量x的值,而不是真正的去变量x所在的内存地址去取值。因此,若变量x被明确修饰为const常量时,我们不应该想方设法去修改x的值,这样会产生不可预料的后果。

为了避免上述问题,我们可以使用volatile去修饰变量x,来告知编译器这个变量值是多变的,在取变量值时编译器会从该变量的内存地址取,从而避免由于编译器优化产生的取值错误。(volatile的作用是作为指令关键字,确保本条指令不会因编译器的优化而省略,且要求每次直接读值。)

volatile const int x = 0; // const修饰变量x为常量
int *ptr = (int*)&x;
(*ptr) = 1;
cout<<x<<endl;  // 输出1

2.1.2 cosnt修饰指针变量

常量指针:const修饰指针指向的内容,即指向常量的指针。指针指向的变量x不可通过指针p改变其值,简称左定值,即const位于*号的左边。

int x = 0;
// 以下两种写法都是常量指针
const int *p = &x;  
int const *p = &x; 

指针常量:const修饰指针,指针自身为常量,即指针指向的内存地址不可改变,但内存中存储的值可以被改变,简称右定值,即const位于*号的右边。

int x = 0; 
int* const ptr = &x; 
*ptr = 1; // 正确
int y = 0;
ptr = &y; // 错误 

指向常量的指针常量:常量指针与指针常量的合并,使用两个const修饰,指针指向不可改变,指向的内容也不可以改变。

int x = 0;
const int * const  ptr = &a;

2.1.3 const 修饰函数参数和返回值

const修饰函数参数 如果函数参数确实是常量语义,那么应该明确使用const修饰函数参数中的指针和引用,可以避免函数在调用过程中产生不正确的篡改

struct student;
// 在函数中对ptr指针的修改和stu1变量的修改都是不允许的
void func(const student* const ptr, const student& stu1); 

const修饰函数返回值 如果函数的返回值是指针类型,且使用const修饰返回指针,那么根据const修饰指针的位置进行常量限制,且该返回值只能赋值给同类型的指针。

const char * getString(); // 函数声明,返回值类型是char类型的常量指针
char* pStr = getString(); // 错误,不能将const char* 赋值给char*类型变量
const char *pStr = getString(); // 正确

2.1.4 const 修饰类成员函数

const修饰类成员函数作用是限制成员函数不能修改成员变量的值,但const不能与static共同修饰类的成员函数,因为static修饰的类成员函数在调用时不通过this指针不能实例化,const成员函数必须关联到具体的实例。

class student 
{
    public:
        student(std::string name) : m_name(name) {}
        std::string getName() const
        {
            // 在这一成员函数中不允许修改成员变量的值
            return m_name;
        }
    private:
        std::string m_name;
};

2.1.5 面试考点const的作用和意义是什么?

cosnt实现了常量的语义,在程序设计中一些不会发生变化或不应被修改的变量应明确使用const进行常量修饰,以取得编译器的帮助。

  • const用来修饰普通变量为常量不被修改;
  • const修饰指针变量,根据const所在的不同位置限制指针指向的修改和指向变量被修改的能力;
  • const用来修饰函数参数和返回值,函数在调用过程中const参数不会被修改;
  • const修饰类的成员函数,该成员函数在调用过程中无法修改成员变量的值。

2.2 static静态语义

2.2.1 static修饰局部变量

无论是内置类型还是自定义类型的局部变量和指针,函数中的局部变量和指针会在栈空间开辟内存,当函数调用结束时从栈中弹出。但如果我们想保存局部变量的值到后续的函数调用中,我们可能会考虑使用全局变量,但全局变量的缺点是放大了该变量的访问范围(语义上是局部变量,但却成为了全局变量,产生了不必要的风险)。

static修饰局部变量为静态局部变量可用于解决这一问题,静态变量的内存位置则从栈区移动到了全局(静态)存储区,局部变量的生命周期得到延续(全局静态存储区在进程运行结束时自动释放)。且无论函数调用多少次,static修饰的静态局部变量只执行初始化一次。

#include <iostream>
using namespace std;
int func(){
    static int count = 10; // 在第一次进入这个函数的时候,static变量count被初始化为10,且只初始化一次。
    return count--;  // 每次执行func函数都对静态count变量进行--
}
int count = 1;  // 全局的count变量
int main()
{
     for(; count <= 10; ++count)
        cout<<count<<func()<<endl;
     return 0;
}

static修饰符另一个作用就是限制变量可见性,被static习俗hi得静态变量只能在本文件中访问,其他文件即使加了extern外部声明也是不可微风该变量。这一特性哦ing可以比卖你在不同文件中定义的同名函数和同名变量产生的命名冲突

static可为程序员提高效率,因此static修饰的变量存储在内存的全局(静态存储区),在全局(静态存储区)所有内存字节默认值为0x00,及变量值默认都是0.因此,当程序在初始化一个矩阵值全为0时,不需要进行2层循环去遍历每个数组成员,只需将数据声明为static即可。

2.2.2 static修饰类成员

被static修饰的成员函数和成员变量是属于类的静态资源,类的所有实例共享,不能通过this指针访问静态资源。

  • static修饰成员变量 static修饰类的成员变量,则该成员变量是类的静态变量,进程内存空间中只有一个副本。由于静态成员变量存储在全局静态存储区,与类的对象存储区域无关,因此不能在构造类的对象时(构造函数)中初始化静态成员变量,只能在类的外部进行初始化静态资源。
class student 
{
public:
    static std::string class_name;
};
std::string student::class_name="student";
  • static修饰成员函数 static修饰类的成员函数,则该成员函数被类的所有实例共享,静态成员函数调用过程没有this指针参与,因此静态成员函数调用具有以下的限制: (1)不能在静态成员函数中使用类的非静态成员变量 (2)不能在静态成员函数中调用非静态成员函数 (3)不能把静态成员函数声明为虚函数

2.2.3 面试考点: static关键字的作用和意义是什么?

static实现了静态全局语义

  • static保持内容持久,修饰局部变量时可以是局部变量变为静态全局变量,且变量的作用域不被打破
  • static隐藏变量的可见性,static修饰的变量和函数只能在本文件中使用,与其他文件无法使用
  • static修饰的变量默认初始化为0
  • static修饰类的成员变量和成员函数,使其为该类的所有成员共享,因此无法通过this指针放翁类的static成员,static成员函数也只能放翁类的静态数据和静态成员函数

3、函数调用过程与内联函数

3.1 函数调用过程

函数定义由4个元素组成,分别为函数名、形参列表、返回值和函数主体。C/C++程序启动时会调用main函数,其他函数在代码中调用函数名就是对函数的调用。函数的调用依靠内存中的栈空间。

以下面代码块中的定义的函数调用为例,逐步分析函数调用过程与内存栈空间的变化。

int func(int param_x, int param_y)
{
    int ret = param_x + param_y;
    return ret;
}
int main(int argc, char* argv[])
{
    int x, y;
    func(x, y);  // 在main函数中调用func函数
    return 0;
}

内存的栈空间是向下生长的,即从内存的高地址到低地址的拓展,栈顶的地址要比栈底低。C/C++程序是从main函数开始的,main函数调用其他子函数,子函数同样可以调用自身或者其他子函数,为了实现函数调用、】恢复现场和归还栈空间,每次的函数调用都有一个自己独立的栈帧。栈帧包含形参列表、函数的局部变量、函数的返回地址。并使用两个指针来指向函数栈帧,EBP指针指向函数栈帧的栈底(固定),ESP指向函数栈帧的栈顶(随函数运行变化)。 在本案例中,main函数为调用方,func函数为被调用方,程序的函数调用过程如下:

  • 入栈 :程序启动时,main函数栈帧生成,依次将main函数返回地址入栈和函数参数从右至左压栈。

  • 跳转执行:跳转到main函数执行,在main函数中定义了两个int变量x和y,压入栈中,ESP向下拓展。

  • 调用子函数:main函数中调用func函数,func函数栈帧生成,main函数的EBP栈底指针入栈,当前的ESP赋值给EBP

  • 跳转执行:跳转到func函数执行,在func函数中定义了一个局部变量ret压入栈中,ESP向下拓展。

  • 恢复现场:func函数调用完毕,局部变量ret、形参列表、返回地址依次出栈,ESP随之收缩,EBP恢复到main函数的栈底值。

恢复:main函数调用完毕,局部变量、形参列表、返回值依次出栈。程序结束。

3.1.1面试考点:C++程序调用过程?

  • 1 从main函数开始执行,编译器会将操作系统的运行状态、main函数的返回地址、main的形参列表、main函数的局部变量依次压栈
  • 2 当main函数调用其他函数时,编译器会将main的运行状态压栈,再将子函数的返回地址、子函数的形参列表、子函数的局部变量依次压栈。

3.2 内联函数

内联函数在编译时,编译器会将函数的代码副本放置在内联函数被调用处进行代码展开,对内联函数进行任何修改,需要重新编译该内联函数的所有调用处,因为编译器需要重新更换一次所有的代码,否则将会继续使用旧的函数。 定义内联函数的方式:在函数声明前加inline关键字

inline int add(int x, int y);

在类定义中的定义的函数(即使未使用inline修饰)都是内联函数,例如:

class person
{
public:
    int add(int x, int y)    // 未使用inline修饰编译器也会将其视为内联函数
    {
        return x + y;
    }
}
  • 优点:内联函数在函数调用处进行代码展开,取消了函数的参数压栈等调用过程。当函数体比较小或函数实现机制比较简单的时候, 内联该函数可以令目标代码更加高效。
  • 缺点:若内联函数函数体复杂或代码量较多,可能使目标代码量陡增,从而降低效率。因此,一般内联函数的函数体不要超过10行代码。此外,递归函数,或者函数中有switch或循环表达式时不应声明为内联函数,虚函数也不会被编译器正常内联。

面试考点:内联函数与宏定义有什么区别?

内联函数与宏定义的区别: (1)宏定义是在预处理过程中进行宏替换,内联函数是编译器控制进行代码展开。 (2)内联函数在编译器会像普通函数一样具有参数类型推导和安全检查,而宏只是宏替换。 (3)内联函数可以访问对象的私有成员函数,例如定义类的成员存取函数。