欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

C++指针小结

程序员文章站 2022-07-03 14:49:15
...

断断续续地学C++也有一两个月了,好歹把基本理论看了几遍,然而越学越觉得这门语言的确是博大精深。作为一个C++小白,也试着对学过的东西做一下总结。这一篇对指针做一下小结。

C++中的指针可以说是C++中的关键技术,其使用灵活并且应用广泛。本文试着对C++中的指针做个小结,主要参考的资料有《C++ Primer Plus》,《Essential C++》和网上的一些博客。

文章基本结构如下:

指向基本类型

首先基本的,指针可以指向基本数据类型,即C++内置的类型,如int,float,doublechar等。下面以指向int类型的指针举例:

#include <iostream>
using namespace std;
int main(int argc, const char * argv[]) {
    // insert code here...
    int a =6;
    int* b = &a;
    cout << b<<" "<< *b<<" "<<&a<<" "<<a<<" "<<endl;
  // 可以通过指针来改变变量的值
    *b = 10;
  	cout << b<<" "<< *b<<" "<<&a<<" "<<a<<" "<<endl;
    return 0;
}

对于指针基本类型的指针,我们可以获得被指向变量的地址,该地址即为指针的值,也可以通过解引用*来获得被指向的变量的值,还可以通过指针来改变变量的值。

在数组中的应用

可以定义一个指针来指向一个数组,此时的指针指向的其实是数组的首元素,也即保存了数组首元素的地址。而且,数组名本身就可以看出一个指向数组首元素的指针,但是两者也有不同之处,《C++ Primer Plus》中说两者有两个区别:

  • 指向数组的指针可以改变,即指向可以改变,如从指向第一个元素改成指向第二个元素,但是数组名不可以改变,是常量,即只指向了数组首元素。
  • 对两者进行sizeof指向数组的指针得到的是4(32位系统下),而后者得到的是整个数组的所占内存。

通过指针算术可以对数组进行操作,可能会觉得这里指针的作用有点像迭代器,其实确实有相似之处,而且在《Essential C++》中第三章泛型编程风格中,作者在开始的第一部分就首先介绍了指针的算术运算,然后引出了迭代器的概念。

关于指针的算术运算举例如下:

double *p;
double balance[10];

p = balance; //等价于 p = &balance[0];
// 指针算术
cout << *p << " " << *(p+1) <<endl;

*p = 1;
*(p+1) = 2;
cout << *p << " " << *(p+1) <<endl;
// 这里的*(p+i)即balance[i]

通过一个指向数组的指针,我们可以通过指针算术来对数组进行操作。
需要注意的是,指向数组的指针并不是指向了整个数组元素块,而是仅仅指向了数组的首元素。

与函数相关的应用

与函数相关的应用,这里总结两个,第一个是指向函数的指针即函数指针,第二是指针作为函数的参数,涉及到了经典的值传递和地址传递的问题。

指向函数

函数指针的定义对于初学者是容易让人困惑的,比如首先应该区分下面的两个定义:

// 区分下面两个语句:
int (*p)(int a, int b); //p是一个指向函数的指针变量,所指函数的返回值类型为整型

int *p(int a, int b); //p是函数名,此函数的返回值类型为整型指针

这也是一个比较容易混淆的点,即所谓的函数指针和指针函数,函数指针的定义和普通的函数相比,比较特殊的地方就是中间的名称部分,在返回类型和参数列表上没有区别。
当我们声明了上面的函数指针之后,意味着此时的函数指针p可以指向任意一个返回类型和参数列表和该函数指针想匹配符合的函数,进而通过函数指针可以间接调用被指向的函数。(和之前说过的通过指针修改基本类型变量的值或者修改数组的值是不是有点类似?即通过指针可以间接对被指向的对象进行一些行为。)

定义了一个函数指针之后,该函数指针就可以不止指向一个函数了,这种灵活性从一定程度上可以帮助我们减少代码量,比如通过维护一个函数指针数组来达到比较简洁地调用多个函数的作用:

void t1(){cout<<"test1"<<endl;}
void t2(){cout<<"test2"<<endl;}
void t3(){cout<<"test3"<<endl;}
 
int main(int argc, const char * argv[])
{
    
    typedef void (*fp)(void);
    fp b[] = {t1,t2,t3}; // b[] 为一个指向函数的指针数组
    b[0](); // 利用指向函数的指针数组进行下标操作就可以进行函数的间接调用了
    // 可以通过下面的代码来循环间接调用3个函数,而且代码量少
  	for(int k=0;k<3;k++){
      b[k]();
    }
  
    return 0;
}

由于函数t1,t2,t3返回类型和参数列表相同,所以可以说它们很’相似’,那么此时通过函数指针数组就能比较方便的间接调用这三个函数。

作为函数参数

指针可以作为函数的参数,此时传入函数的就是变量的地址,在函数内部形参会成为一个局部的指针,来指向该变量,从而在函数内部的通过该局部指针对该变量进行的操作都会对该变量产生实际影响,而值传递传入是变量的副本,所以在函数体内部无论对副本进行什么操作,都不会影响原来的。

经典的例子就是交换两个变量的值函数:

// 代码来自:https://blog.csdn.net/ccblogger/article/details/77752659?depth_1-utm_source=distribute.pc_relevant.none-task&utm_source=distribute.pc_relevant.none-task

/* 
  pass By value 
*/  
#include <iostream>  
using namespace std;  
void swap(int,int);  
void swap(int*, int*);
// 这里发生了函数重载
int main()  
{  
    int a = 3, b = 4;  
    cout << "a = " << a << ", b = "  << b << endl;  
    swap(a,b);  
    cout << "a = " << a << ", b = "  << b << endl;  
    
    swap(&a,&b);  
    cout << "a = " << a << ", b = "  << b << endl; 
    return 0;  
}  
void swap(int x, int y)  
{  
     int t = x;  
     x = y;  
     y = t;  
}  

void swap(int *x, int *y){
	int t = *x;
	*x = *y;
	*y = t;
}

可以看到通过指针作为函数的参数,能够达到改变原变量的效果。

指针作为函数参数,还有一个目的就是减少内存方面的消耗,如果是值传递,当变量内存比较大时,此时再在函数内部形成局部的副本,就会带来较大的开销,所以出于这个考虑也会把指针作为函数的参数。

那么即使是地址传递,难道不会产生任何的额外内存开销吗?答案是否定的,即使传入的某个变量的地址,在函数内部就会形成一个局部的指针(这就是额外的开销)来指向该变量(也即该局部指针的值是该变量的地址),在函数体内部真正干活的就是这个局部的指针,当它干完活就会销毁了。当然,产生局部的指针肯定比直接对变量进行拷贝副本内存开销要小得多了,所以用指针作为函数参数确实能减少内存的开销。

引用

引用即是一种const pointer,引用在声明时必须进行初始化,而且初始化后不可以再改变引用,即指向不能改变。

在这里提一下一个常见的混淆点,所谓的指针常量和常量指针的概念,对初学者来说这两个点容易让人混淆,区分下面的代码:

// 代码来自:https://*.com/questions/1143262/what-is-the-difference-between-const-int-const-int-const-and-int-const#

int* - pointer to int
int const * - pointer to const int
int * const - const pointer to int
int const * const - const pointer to const int

一个比较好的辨别方法,我们可以参考《Effectivec++》Item3上的做法,见
https://harttle.land/2015/07/21/effective-cpp-3.html

如果const位于星号的左侧,则const就是用来修饰指针所指向的变量,即指针指向为常量,即pointer to const;

如果const位于星号的右侧,const就是修饰指针本身,即指针本身是常量,即const pointer。

引用也可以作为函数的参数,相比指针,其使用起来更为语法上更为简洁。但是由于其指向不能改变,所以灵活性不如指针灵活。

上面的swap函数中的参数也可以是引用,这样代码更简洁一些:

#include <iostream> 
using namespace std;
 
void swap(int&, int&);
int main(){
	int a = 3, b = 4;
	cout << "a=" << a << ", b=" << b << endl;
	swap(a, b);
	cout << "a=" << a << ", b=" << b << endl;
	system("pause");
	return 0;
}

void swap(int &x, int &y){
	int t = x;
	x = y;
	y = t;
}

引用是变量的别名,操纵引用就跟操作原变量一样。

同样的,在函数调用过程中,也会形成局部的引用,该局部引用完成修改变量的值的作用后,在调用结束被销毁。

与new搭配管理内存

通过运算符newdelete搭配,可以进行动态内存的管理,其基本使用如下:

int *a = new int[5];
class A {...}   //声明一个类 A
A *obj = new A();  //使用 new 创建对象
delete []a;
delete obj;

当使用new来创建类的对象时,会自动调用该类的构造函数,在对象使用结束后会自动调用类的析构函数。这也是new/delete和库函数malloc/free主要的区别。注意new/delete不是库函数,是C++运算符。

new的另外一个重要作用,就是在当类中有指针类型的成员时,会在我们自定义拷贝构造函数重载赋值运算符时起到关键作用,这里面涉及到的问题就是经典的深拷贝和浅拷贝的问题,在后面会说道这个。

在面向对象中的应用

在面向对象中,指针的应用也十分广泛,小结如下:

  • 可以定义指向类对象的指针来间接调用该对象的数据成员和成员函数;
  • 可以像函数指针类似来定义指向类成员函数的指针;
  • 由于类的数据成员和成员函数在内存中分开储存,进而引进this指针来时刻指向对象实例本身,从而区分不同对象对同一成员函数的调用;
  • 当类中含有指针类型的数据成员时,我们往往会自己给出拷贝构造函数和重载赋值运算符,如果不这样就会带来重复析构的问题。在自定义构造函数和重载赋值运算符时,会运用到new来进行新内存的开辟来确保不会有两个指针指向同一块内存的情况。这个问题也就是经典的浅拷贝和深拷贝的问题;
  • 在多态中,多态实现的必不可少的一个条件就是通过基类类型的指针来指向派生类的对象,进而实现多态。实现多态必须通过指针或者引用才可以;
  • 在多态的底层实现中,其关键在于虚函数和虚函数表的技术。当某个成员函数被声明为虚函数时,每个对象会多出一块内存来保存虚函数表的地址,也即多了一个指针来指向虚函数表,而虚函数表本质上就是函数指针数组,该数组每个元素都是一个函数指针,指向了本类对应的虚函数。在继承时,维护好该函数指针数组就是实现多态的关键。

可以看出指针在面向对象中的应用确实十分广泛而且重要,下面一一介绍。

指向类对象

当定义了指向类对象的指针后,可以通过指针来访问对象的成员函数和数据成员,访问方式是通过->,还要注意的是声明指向类对象的指针后,必须进行初始化。

class c1{
public:
  int a;
  int b;
}

int main(){
  c1 c_ins = {1,2};
  c1* p_class = &c_ins;
  p_class->a = 20;// 通过指针访问
  (*p_class).b = 300; //通过指针进行运算
  cout << c_ins.a <<" "<<c_ins.b <<endl;
}

这里再给出一个例子,以结构体(结构体除了默认的访问权限和继承方式和类不同,其他地方差不多)为例,实现了链表。

// 代码来自: https://www.cnblogs.com/kingwenwu/p/4531788.html

#include<iostream>
#include<string>
using namespace std;
struct Candidate{
 string name;
 int count;
 Candidate *next;//定义了指向Candidate类型变量的指针
};
int main(){
    Candidate c_leader[3];
 c_leader[0].name="Tom";
 c_leader[0].count=5;
 c_leader[0].next=&c_leader[1];
 c_leader[1].name="Nick";
 c_leader[1].count=9;
 c_leader[1].next=&c_leader[2];
 c_leader[2].name="Jim";
 c_leader[2].count=10;
 c_leader[2].next=NULL;
 Candidate *p=c_leader;
 while(p!=NULL){
  cout<<p->name<<":"<<p->count<<endl;  //也可以(p+1)->name...来实现
  p=p->next;
 }
 return 0;
}

从代码中可以看出,其关键在于在结构体中定义了一个指向本类对象的指针。

指向类成员函数

指向类成员函数的指针和函数指针的定义很像,需要注意的就是要加上类作用域运算符::

// 主要参考:http://www.cppblog.com/colys/archive/2009/08/18/25785.html
https://www.cnblogs.com/AnnieKim/archive/2011/12/04/2275589.html

//一个指向外部函数的指针(也即普通的函数指针)声明为:

void (*pf)(char *, const char *);

void strcpy(char * dest, const char * source);

pf=strcpy;

//一个指向类A成员函数的指针声明为:
void (A::*pmf)(char *, const char *);

对该类成员函数指针进行赋值

class A
{

  public:

   void strcpy(char *, const char *);

   void strcat(char *, const char *);

};
pmf = &A::strcpy;  // 标准C++强制要求加上&号。
// 注意声明和赋值时的类名的限定,这是函数指针所没有的

如何通过该类成员函数指针取调用类中的成员函数呢? 两种方法,通过对象或者指向对象的指针,两种方法都必须有实例对象才行。

(类对象.*指针名)(参数列表);
(类指针->*指针名)(参数列表);

这里的指针名即类成员函数指针。看下面的例子:

class A;
typedef void (A::*NONSTATICFUNCPTR)(int);    //typedef

class A
{
public:
    void NonStaticFunc(int arg) 
    {
        nonStaticMember = arg; 
        cout<<nonStaticMember<<endl;
    }
private:
    int    nonStaticMember;
};

int main()
{
    NONSTATICFUNCPTR funcPtr= &A::NonStaticFunc;

    A a;
    (a.*funcPtr)(10);        //通过对象调用

    A *aPtr = new A;
    (aPtr->*funcPtr)(10);    //通过指针调用

    return 0;
}

类成员函数的调用初看令人困惑,我觉得可以通过下面的角度看:

既然类成员函数指针指向了类成员函数,那么对该指针进行解引用*操作,自然就得到了该类成员函数,即类成员函数指针所指变量的值就是类成员函数。

从这个角度看类成员函数指针两种调用方法,无论是通过类对象的.*方式还是通过类对象指针的->*方式,后面的*操作相当于解引用,解引用后就得到了类成员函数,这样就和一般的(不借助类成员函数指针的方法)通过类对象.fun()或者类对象的指针->fun()调用一样了。

this指针

由于类中的数据成员和成员函数是分开储存的,即不同的对象拥有不同的数据成员但是其成员函数是公用一个,为了区分和识别到底是哪个对象在调用成员函数,就引入了this指针的技术。

C++中的this指针就是Python中的self方法。

由于this指针的引入,使得某个对象在调用成员函数时,该对象的地址会作为成员函数的默认参数传入,也即this指针的值就是正在调用成员函数的对象的地址,也即this指针时刻指向对象本身,这样对成员函数来说就知道了到底是谁在调用它。

this指针的经常使用的场景至少有下面3个:

  • 在类的非静态成员函数(静态成员函数用不着this指针,因为静态成员是刻画类的整体的性质,独立于类的具体对象)中返回类对象本身的时候,直接使用 return *this;例如实现对象的链式引用

  • 参数与成员变量名相同时,要使用this->n = n进行数据成员初始化 (不能写成n = n)。

  • 避免对同一对象进行赋值操作。

下面分别举例:

代码来自:https://www.cnblogs.com/wkfvawl/p/10578889.html

// 链式引用
#include <iostream>
using namespace std;
class Person
{
public:
    Person(string n, int a)
    {
        name = n;    //这里的 name 等价于this->name
        age = a;      //这里的 age 等价于this->age
    }
    int get_age(void) const
    {
        return age;
    }
    Person& add_age(int i)
    {
        age += i;
        return *this;  // 返回本对象的引用
    }
private:
    string name;
    int age;
};
int main(void)
{
    Person Li("Li", 20);
    cout<<"Li age = "<< Li.get_age()<<endl;
    cout<<"Li add age = "<< Li.add_age(1).get_age()<<endl;
    //增加1岁的同时,可以对新的年龄直接输出;
    return 0;
}

// 参数与成员变量名相同的处理
#include <iostream>
using namespace std;
class Point
{
public:
    int x;
    Point ():x(0) {}
    Point (int a)
    {
        x=a;
    }
    void print()
    {
        cout<<"x = "<<x<<endl;
    }
    void set_x(int x)
    {
    		this-> x =x;
        // x = x; 不能这样写
    }
};
int main()
{
    Point pt(5);
    pt.set_x(10);
    pt.print();
    return 0;
}

// 避免同一对象重复赋值
#include <iostream>
using namespace std;
class Location
{
    int X,Y;  //默认为私有的
public:
    void init(int x,int y)
    {
        X =x;
        Y =y;
    };
    void assign(Location& pointer);
    int GetX()
    {
        return X;
    }
    int GetY()
    {
        return Y;
    }
};
void Location::assign(Location& pointer) // 赋值函数
{
    if(&pointer!=this) //同一对象之间的赋值没有意义,所以要保证pointer不等于this
    {
        X=pointer.X;
        Y=pointer.Y;
    }
}

int main()
{
    Location x;
    x.init(5,4);//初始化赋值
    Location y;
    y.assign(x);
    cout<<"x.X = "<< x.GetX()<<" x.Y = "<<x.GetY();
    cout<<"y.X = "<< y.GetX()<<" y.Y = "<<y.GetY();
    return 0;
}

下面是关于一个this指针的经典回答:

当你进入一个房子后,
你可以看见桌子、椅子、地板等,
但是房子你是看不到全貌了。

对于一个类的实例来说,
你可以看到它的成员函数、成员变量,
但是实例本身呢?
this是一个指针,它时时刻刻指向你这个实例本身

该回答来自:https://blog.csdn.net/feiyond/article/details/1652505

深拷贝和浅拷贝

在类中,我们一般都要自己来实现所谓的Big-Three,即构造函数(包括拷贝构造函数),析构函数和赋值函数。

如果采取默认的方式,当类中包含了指针类型的数据成员时,当进行拷贝或者赋值操作时,一定会出错。这是因为默认的拷贝构造函数和赋值函数都是位拷贝(浅拷贝)而不是值拷贝(深拷贝),这就意味着当采用这种默认的方式进行拷贝或者赋值时,会出现两个指针(分别是原对象中的和拷贝(赋值)后的对象中的)指向了同一块内存的情况,那么在析构时就会出现重复析构的问题,所以我们要利用new来开辟新内存来单独为新对象的指针成员使用,即要自定义拷贝构造函数和重载赋值运算符=,并且用delete自定义析构函数。

同时这里也要注意拷贝构造函数和赋值运算的区别:

拷贝构造函数是一个对象初始化一块内存区域,这块内存就是新对象的内存区,而赋值函数是对于一个已经被初始化的对象来进行赋值操作。

这里引用博客https://blog.csdn.net/u010700335/article/details/39830425
中的简洁易懂的例子:

// 没有进行拷贝构造函数,析构函数和赋值函数的自定义
// 此时的数据成员中也没有指针,所以此时深拷贝和浅拷贝没有什么区别
class A 
{ 
	public: 
	A(int _data) : data(_data){}  // 带参数的构造函数
	A(){} // 默认构造函数
	private: 
	int data;
 };
int main() 
{ 
	A a(5), b = a; // 仅仅是数据成员之间的赋值 
}

但是当类的数据成员有指针时,情况就大不一样了:

class A 
{ 
	public: 
	A(int _size) : size(_size)
	{
		data = new int[size];
	} // 假如其中有一段动态分配的内存 
	A(){};
	 ~A()
	{
		delete [] data;
	} // 析构时释放资源
	private: 
	int* data;
	int size; 
}
int main() 
{ 
	A a(5);
	A b = a; // 注意这一句 
}

上面的代码没有拷贝构造函数,所以在拷贝的时候,会出现对象b和对象a中分别的指针类型的数据成员data会指向同一块内存,这样在析构的时候,对象a和对象b都会调用析构函数,而导致同一块内存被释放了两次,这就必然产生问题,所以要自定义拷贝构造函数:

class A 
{ 
	public: 
	A(int _size) : size(_size)
	{
		data = new int[size];
	} // 假如其中有一段动态分配的内存 
	A(){};
	A(const A& _A) : size(_A.size)
	{
		data = new int[size]; // 关键在这里,当进行拷贝的时候,为新对象的指针利用new来开辟一块内存供其使用
	} // 深拷贝 
	~A()
	{
		delete [] data; 
	} // 析构时释放资源
	private: 
	int* data; 
 	int size;
 }
int main() 
{ 
	A a(5);
	A b = a; // 这次就没问题了 
}

从上面的代码中可以看到,当类中的数据成员有指针类型成员时,必须要对构造函数和赋值运算进行自定义,否则就会导致重复析构的问题。

当不想自定义拷贝构造函数和赋值函数,但又不想让别人进行拷贝构造和赋值运算时(因为一旦使用就有可能出问题),有一种偷懒的方法,那就是把拷贝构造函数和赋值运算声明为private成员,这样就无法进行拷贝和赋值运算了。

实现多态

下面是构成多态的条件:

  • 存在继承关系

  • 向上转型,即基类类型的指针(也即指向基类对象的指针)指向了派生类的对象(引用也可,但是灵活性稍差)

  • 基类的成员函数是虚函数

  • 派生类的成员函数重写了基类的虚函数,所谓重写:即函数返回值类型,函数名和参数列表完全一致称为重写

这样就可以动态地根据基类指针所指派生类对象的不同来进行不同行为,进而实现了多态。

下面是一个例子

// 代码来自:http://c.biancheng.net/view/2294.html

#include <iostream>
using namespace std;

//基类People
class People{
public:
    People(char *name, int age);
    virtual void display();  //声明为虚函数
protected:
    char *m_name;
    int m_age;
};
People::People(char *name, int age): m_name(name), m_age(age){}
void People::display(){
    cout<<m_name<<"今年"<<m_age<<"岁了,是个无业游民。"<<endl;
}

//派生类Teacher
class Teacher: public People{
public:
    Teacher(char *name, int age, int salary);
    virtual void display();  //声明为虚函数
private:
    int m_salary;
};
Teacher::Teacher(char *name, int age, int salary): People(name, age), m_salary(salary){}
// 派生类重写基类的虚函数
void Teacher::display(){
    cout<<m_name<<"今年"<<m_age<<"岁了,是一名教师,每月有"<<m_salary<<"元的收入。"<<endl;
}

int main(){
    People *p = new People("王志刚", 23); // 当基类指针指向基类对象时,调用基类的方法
    p -> display();

    p = new Teacher("赵宏佳", 45, 8200); // 当基类指针指向派生类对象时,调用派生类方法
    p -> display();

    return 0;
}

在多态的实现中,必须通过基类类型的指针或者引用指向派生类对象才可以,在这里指针又扮演了极为关键的角色。

虚函数表的维护

多态的底层实现中也使用了大量的指针,这里有两点:

  • 如何根据一个类的对象获得该类的虚函数表

  • 在继承中虚函数表是如何变化的

下面分别阐释:

如何根据一个类的对象获得该类的虚函数表

网上基本都是通过下面的代码来示例如何通过对象获得该类的虚函数表的:

class Base {
public:
    virtual void f() { cout << "调用了第一个虚函数 Base::f()" << endl; }
    virtual void g() { cout << "调用了第二个虚函数 Base::g()" << endl; }
    virtual void h() { cout << "调用了第三个虚函数 Base::h()" << endl; }
};

typedef void(*Fun)();  // 声明函数指针类型
int main(int argc, const char * argv[]) {
    Base b;
    Fun pFun = NULL;
    cout << sizeof(b)<<endl;
    cout << "虚函数表的地址为:" << *(long*)(&b) << endl;
    for(int i=0;i<3;i++)
    {
    cout << "虚函数表的第"<<i+1<<"个函数地址为:" <<
                    *((long*)*(long*)(&b)+i) << endl;
    pFun = (Fun)(*((long*)*(long*)(&b)+i));
    pFun();
        
    }
  return 0;
}

问题的困扰处在于下面代码的含义

*((long*)*(long*)(&b)+i)
// 在32位中,应该把long换成int
*((int)*(int*)(&b)+i)
 
 // 调用第i个虚函数
 pFun = (Fun)(*((long*)*(long*)(&b)+i));
 pFun(); 
  

首先,当基类中的某个成员函数声明为虚函数时,基类的每个对象都多出一块内存来存放一个地址(__vptr),该内存在存放对象的内存的**最开始部分。**这是为了保证在多层继承或多重继承的情况下能以最高效率取到虚函数表。

该地址就是该类的虚函数表的地址,指针类型的sizeof都是4个字节(32位下为4,64系统下为8)。

每个类有一张虚函数表,而虚函数表是一个函数指针数组,其每个元素都是一个函数指针,保存了对应的虚函数的地址。

如下图,每个类的对象都会多出来一部分额外的__vptr来保存其整个类的虚函数表(这一定程度上已经带来内存消耗了,这或许是多态的缺点),该虚函数表示一个函数指针数组,保存着对应虚函数的地址。

图片
(图片来自https://blog.csdn.net/lihao21/article/details/50688337
这篇博客是我感觉讲的比较清楚的。)

当声明了一个类的对象时,如上面的代码中的Base b时,如何通过该对象找到整个类的虚函数表?(通过一个类的对象就可以找到整个类的虚函数表),下面对代码进行拆解:

Base b // 声明一个对象
&b  // 该对象的首地址 
    //注意看,以对象的首地址为开始,往后的4个字节(以32位举例)就是虚函数表的地址,如何取到?
    // 利用指针的强制转换
(int *)&b //把&b强制转化为一个指向int类型的指针,那么此时该指针的sizeof就是4,该指针指向了虚函数表的地址,即此时该指针指向的对象的值就是虚函数表的地址。这样通过该指针就可以正好把虚函数的表的地址拿出来了,即此时相当于 int* p = (int *)&b,解引用*p即是该虚函数表的地址
*(int *)&b // 这样才可以拿到虚函数表的地址 而不是一些博客中写的:(int *)&b,必须加上前面的*
// 目前我们已经拿到了虚函数表的地址,虚函数表示一个函数指针数组,那么一个数组的地址是什么? 是数组首元素的地址,再次使用指针强制转换,得到一个指向该数组首元素的指针
(int *)(*(int *)&b) // 相当于 int * p_num = (int *)(*(int *)&b),本质上此时的p_num是指向一个数组的指针,那么此时可以利用p_num进行指针算术了,就像之前用指针操纵一个一般数组一样,如*(p_num+0)就得到该数组的第一个元素的值,*(p_num+1)就得到该数组的第二个元素的值,只是这个数组非常特殊,它是函数指针数组,这时候的数组的第一个元素即是第一个虚函数的地址,第二个元素就是第二个虚函数的地址,一次类推。所以如何得到某个虚函数的地址? 
  
*((int *)(*(int *)&b)+i)// 这样就得到了第i个虚函数的地址,就是第i个虚函数的入口地址,把该地址赋给一个函数指针,就可以通过该函数指针间接调用该函数了。前面必须有*才对
pFun = (Fun)(*((int*)*(int*)(&b)+i));  //再次使用指针的强制转换,只不过此时右边的地址是一个函数的,所以要转为函数指针类型的,那么下面就可以通过函数指针来间接调用右边的地址所对应的函数了
pFun();// 间接调用了第i个虚函数

可以看出在获得虚函数表时进行了大量的指针运算,这一部分比较烧脑。

在继承中虚函数表是如何变化的

在继承时,维护派生类的虚函数表是实现多态的关键,这里的维护即派生类的函数指针数组中每个函数指针的指向,有下面几条重要事实:

  • 一个继承类的基类如果包含虚函数,那个这个继承类也有拥有自己的虚表,故这个继承类的对象也包含一个虚表指针,用来指向它的虚表。
  • 如果基类的直接派生类的成员函数没有重写基类的声明为virtual的成员函数vf1,那么在派生类的虚函数中,会增加指向该虚函数vf1的函数指针,即会增加保存该虚函数vf1的地址。
  • 如果基类的直接派生类的成员函数重写了基类的声明为virtual的成员函数vf2,即在直接派生类中也一个和基类一模一样的成员函数vf2,那么在派生类的虚函数表中,会只保留指向该派生类的虚函数vf2的函数指针,即只保留派生类自己的虚函数vf2,会覆盖掉基类的同名虚函数。正是这种覆盖,才使得多态成为可能。

以下面的例子为例
代码和图片来自https://blog.csdn.net/lihao21/article/details/50688337

class A {
public:
    virtual void vfunc1();
    virtual void vfunc2();
    void func1();
    void func2();
private:
    int m_data1, m_data2;
};

class B : public A {
public:
    virtual void vfunc1();
    void func1();
private:
    int m_data3;
};

class C: public B {
public:
    virtual void vfunc2();
    void func2();
private:
    int m_data1, m_data4;
};

图片

由于这三个类都有虚函数,故编译器为每个类都创建了一个虚表,即类A的虚表 (A vtbl),类B的虚表(B vtbl),类C的虚表(C vtbl)。类A,类B,类C的对象都拥有一个虚表指针,*__vptr,用来指向自己所属类的虚表。

A包括两个虚函数,故A vtbl包含两个指针,分别指向A::vfunc1()A::vfunc2()

B继承于类A,故类B可以调用类A的函数,但由于类B重写了B::vfunc1()函数,故B vtbl的两个指针分别指向B::vfunc1()A::vfunc2()。这里派生类的虚函数表就发生了改变,首先继承了派生类没有的基类中的虚函数,并且用派生类中重写的虚函数对基类的同名虚函数进行了覆盖,此时函数指针数组中的对应的函数指针的指向就会发生改变。

C继承于类B,故类C可以调用类B的函数,但由于类C重写了C::vfunc2()函数,故C vtbl的两个指针分别指向B::vfunc1()(指向继承的最近的一个类的函数)和C::vfunc2()

所以通过上面的规则更新了派生类的虚函数表,在利用基类指针来调用派生类的成员函数时,就可以直接依照着派生类的虚函数表来找到底调用哪个成员函数了。

可以看出,上面的重写规则继承保留最近基类的原则是非常重要的。


可以看出,在面向对象中,指针的使用极为广泛而且灵活,让人大开眼界的同时也让人头秃。

另外,这部分更为深入的部分就是对象模型了,有兴趣的可以自行深入学习,这里推荐两个比较好的文章:

https://www.cnblogs.com/QG-whz/p/4909359.html#_label5

https://www.cnblogs.com/kunhu/p/3631285.html

迭代器

迭代器是一种泛型指针,在STL中应用广泛,也是理解STL的关键所在。其具体的用法可能大家都会,这里着重谈一谈如何理解迭代器。

C++ Primer Plus中有这样一句话:

有了模板,我们可以使得算法独立于特定的数据类型,而有了迭代器,我们可以使得算法独立于特定的容器类型。

对于前者,很好理解,因为模板技术本身就是为了使得代码能够独立于数据类型而引入的,比如函数模板,其返回值类型和参数类型不具体指定,运行时可以进行类型的自动推导,这样就使得算法可以独立于特定的数据类型。

而对于后者如何理解?C++ Primer Plus中首先提出了这样的一个问题,比如现在想要写一个find函数,来查找某个容器中是否含有某个值。这里以数组和链表进行举例,两种容器可以实现不同的find函数,代码如下:

首先是对double类型的数组写find函数

double* find_ar(double* ar,int n,const double &val){
    for(int i=0;i<n;i++){
        if(ar[i] == val){
            return &ar[i];
        }
    }
    return nullptr;
}

我们可以在上面代码的基础上,通过模板技术,使得该函数能够适用于任何类型的数组,但是该函数依然和固定容器数组联系在一起。

而对于链表,也可以实现类似的find函数

struct Node{
    double iterm;
    Node* p_next;
};

Node * find_ll(Node * head,const double & val){  
    Node * start;
    for(start=head;start != nullptr ;start = start->p_next){
        if(start->iterm == val){
            return start;
        }
        return nullptr;
        
    }

我们可以通过模板技术使得上面的函数对任意类型的链表都适用,但是这个函数依然无法独立于链表之外。

我们可以看到,在上面的数组和链表的find函数实现中,指针起到了关键的作用,在这里面都涉及到了指针的一些运算,如比较解引用赋值遍历等,而且都比较相似。

下面一段来自C++ Primer Plus,道出了迭代器的关键:

泛型编程旨在使用同一个find函数来处理数组,链表和其他的容器类型。即函数不仅能独立于容器中的数据类型,而且独立于容器本身的数据结构。模板提供了储存在容器中的数据类型的通用表示,因此还需要遍历容器中的值的通用表示,而迭代器正是这种通用表示

根据容器的各自特点,并且对迭代器的应该有的功能进行了总结,C++提出了5种迭代器:输入迭代器,输出迭代器,正向迭代器,双向迭代器和随机访问迭代器。这5种迭代器就满足了各种容器的遍历容器中的值的需求,这样就达到了算法和容器的分离,同一个算法可以通过迭代器为桥梁对不同容器的值进行遍历从而施加算法操作,这就是所谓的算法独立于容器类型。

关于迭代器的具体使用,这里不再细说,很多教材都很详细,这里主要谈的是迭代器的理解。

可以看出迭代器是一种泛型指针,是为了能够通用地对容器中的值进行遍历,其主要的功能包括比较解引用赋值遍历等。它的存在使得算法和容器实现了分离。

智能指针

最后简单地说一下智能指针,在C++中使用new/delete进行内存管理时,有时候可能会忘掉写delete,由此会带来内存泄漏的问题。还有,在之前的深拷贝和浅拷贝中说过,如果类中包含了指针类型的成员,就要小心会不会出现重复析构的问题。为了解决这些问题,C++提出了几种智能指针,能够比较安全和方便的管理内存,这里简单总结一下unique_ptr,shared_ptrweak_ptr(auto_ptr已经被C++ 11弃用)。

个人理解,unique_ptrshared_ptr就是在讨论一块内存到底是归一个指针管还是多个指针管的问题。

如果业务中需要的就是一块内存就归一个指针管,那就明确用unique_ptrunique_ptr禁止使用拷贝构造和复制赋值等操作(支持移动,支持移动本质上还是一个指针管理一块内存),而且不用写delete就可以实现内存回收。

如果需求多个指针指向一个内存(这时候如果不借助智能指针,一不小心就很可能重复析构了),那就使用shared_ptrshared_ptr可以进行拷贝构造和赋值,同样的也不用写deleteshared_ptr内部是利用引用计数来实现内存的自动管理,每当复制一个shared_ptr,引用计数会 +1。当一个shared_ptr离开作用域时,引用计数会 -1。当引用计数为0的时候,则 delete 内存。

weak_ptrshared_ptr关系很大,主要是为了解决shared_ptr相互引用时的死锁问题,如果说两个shared_ptr相互引用,那么这两个指针的引用计数永远不可能下降为0,资源永远不会释放。它是对对象的一种弱引用,不会增加对象的引用计数,和shared_ptr之间可以相互转化,shared_ptr可以直接赋值给它,它可以通过调用lock函数来获得shared_ptr

这部分主要参考了:

https://blog.csdn.net/xt_xiaotian/article/details/5714477?depth_1-utm_source=distribute.pc_relevant.none-task&utm_source=distribute.pc_relevant.none-task

https://www.cnblogs.com/tenosdoit/p/3456704.html

https://www.cyhone.com/articles/right-way-to-use-cpp-smart-pointer/

总结

本文主要是对C++中的指针应用做了小结,希望能够较为全面地总结它在各个方面的应用,当然指针技术博大精深,本文只是举一些简单的例子,如果想深入了解,还需要继续学习。

作为初学C++的小白中的小白,作者还有很多不懂的地方。如果文章有什么问题,欢迎拍砖。

相关标签: C++ c++