C++的有些知识点可能不在《C++ Primer,5th》或者《The C++Programming Language,4th》里,作为一个看这两本书的初学者,这有些难办。C++的有些知识藏在犄角旮旯里,但时不时就能碰到,今天就谈谈遇到的成员指针。跟C#或者Java中成员变量或者函数必须定义在类里面不同,C++中的成员变量或者函数既可以写在类的外面,也可以写在类的里面,这或许是为了兼容C的缘故。针对指向的是否是成员变量或成员函数,C++中必须要进行区分,这或许就是成员指针的由来,本文简单介绍C++成员指针中的成员变量指针和成员函数指针。

语法


成员指针声明的语法在cppreference上有专门的介绍:

  • 指针声明: S* D,表示声明一个指针D,它是指向类型S的指针。
  • 成员指针声明: S C::* D 表示声明一个指针D,它是指向类型C中的非静态成员且类型为S的指针。

可以看到,与普通指针相比,成员指针增加了"类名::"限定符。成员指针分为成员变量指针和成员函数指针。

成员变量指针


成员变量指针(pointer to data member)用来保存类的某个数据成员在类对象内的偏移量。它只能用于类的非静态成员变量。成员变量指针的定义格式为:

成员类型 类名::*指针名=&类名::成员名

一个指向类C非静态成员m的成员指针可以用&C::m进行初始化。在C的成员函数里面使用&C::m会出现二义性。即既可以指代对m成员取地址&this->m,也可以指代成员指针。为此标准规定,&C::m表示成员指针,&(C::m)或者&m表示对m成员取地址。可以通过运算符.*和->*来访问对应的成员。比如下面的例子:

struct C
{
    int m;
};

int main()
{
    int C::* p = &C::m;
    C c{7};
    std::cout << c.*p << std::endl;

    c.*p = 8;
    std::cout << c.m << std::endl;

    C *cp = &c;
    cp->m = 10;
    std::cout << cp->*p << std::endl;
    return 0;
}

输出结果为:

7
8
10

上面的成员变量指针指向类C中的成员m,成员指针必须通过类的实例来访问。比如上面的c.*p或者cp->*p里面的c和cp。成员变量指针不能指向类的私有成员,乍一看,如果像上面的这个例子演示的那样,成员变量指针没啥用啊,为什么不直接访问成员对象?

接着看下面这个例子:

struct Student
{
    int age;
    int score;
};

double average(Student *stds, int count, int Student::*item)
{
    int result = 0;
    for (size_t i = 0; i < count; i++)
    {
        // result += (&stds[i])->*item;
        result += stds[i].*item;
    }
    return (double)(result / count);
}

int main()
{
    Student my[3]{{16, 66}, {17, 80}, {18, 70}};
    double ageAvg = average(my, 3, &Student::age);
    double ageScore = average(my, 3, &Student::score);
    std::cout << "ageAvg:" << ageAvg << " avgScore:" << ageScore << std::endl;
}

这里average函数用来统计Student的age或者score字段的平均值。第三个参数用来表示需要统计的字段,它必须是Student类里面类型为int的字段。

在调用的时候,用取地址符&来传入需要的字段是&Student::age还是&Student::score。可以看到在这个场景中成员变量指针还是有些用处的。

总结成员变量指针:

  • 成员变量指针在底层上存放的是对象的数据成员相对于对象首地址的偏移量,所以通过成员变量指针访问成员变量时,需要提供对象的首地址,即必须通过对象来访问,因此成员变量指针从本质上来讲并不是一个指针。
  • 在上面average2函数中,成员变量的类型是int,所以也可以通过偏移量offset来模拟成员指针,比如 stds[i].*item,等同于
    *(int *)(((char *)(&stds[i]) + offset)),但这样的代码可读性和可移植性很差。例如下面的代码中,用int变量offset代替了成员指针变量。在调用的时候,需要传入待统计的字段在对象内的偏移量。age是类的第一个字段,偏移量是0,score是第二个字段,所以偏移量是4,因此传入0和4分别调用average2函数即可以得出与使用成员变量指针一样的结果。
    double average2(Student *stds, int count, int offset)
    {
        int result = 0;
        for (size_t i = 0; i < count; i++)
        {
            result += *(int *)(((char *)(&stds[i]) + offset));
        }
        return (double)(result / count);
    }
    
    int main()
    {
        Student my[3]{{16, 66}, {17, 80}, {18, 70}};
        double ageAvg = average2(my, 3, 0);
        double ageScore = average2(my, 3, 4);
        std::cout << "ageAvg:" << ageAvg << " avgScore:" << ageScore << std::endl;
    }

成员函数指针


函数指针通常用于回调中,成员指针的定义格式如下:

 成员函数返回类型 (类名::*指针名)(形参)= &类名::成员函数名

从定义上可以看到,和普通的函数指针相比,成员函数指针在名称前面多了一个"类名::"的限定符。与成员变量指针类似,成员函数指针通过对成员函数名称取地址符来初始化。

struct Student
{
    int age;
    int score;
    void print()
    {
        std::cout << "age:" << age << " score:" << score << std::endl;
    }
};
 
int main()
{
    Student my[3]{{16, 66}, {17, 80}, {18, 70}};
    void (Student::*pprint)() = &Student::print;
    for (Student& s :my)
    {
        (s.*pprint)();
        //((&s)->*pprint)();
    }
    return 0;
} 

在Student中添加了一个print方法,用来的打印信息。main函数中,定义了一个pprint的成员函数指针。可以看到,成员函数指针的声明中返回值和形参与print函数是一致的。对Stduent::print函数取地址符就可以给成员函数指针赋值。在调用的时候,通过s.*或者s->*来访问定义的成员函数指针。

可以根据传入参数,动态调用成员函数。比如:

struct C
{
    void f(int x) { std::cout << x << std::endl; }
    void g(int x) { std::cout << x + 1 << std::endl; }
};

//using fp = void (C::*)(int);
typedef void (C::*fp)(int);
auto access(C &c, fp pm, int args)
{
    return (c.*pm)(args);
}

int main()
{
    C c;
    access(c, &C::f, 1); // 1
    access(c, &C::g, 1); // 2
    return 0;
}

在C中定义了两个成员函数f和g,他们的形参和返回值系统。接着定义了一个名为fp的成员函数指针类型。在access函数中,将成员函数指针作为第二个形参。在main函数中,可以通过&c::f或者&c::g来动态调用不同的函数。

再举一个例子。有不同的动物,人对不同动物的反应是不一样的。

struct Animal;
enum class AnimalType
{
    Dog = 0,
    CAT,
    PIG,
    LIZARD,
    HORSE,
};

struct Animal
{
    virtual std::string Noise() const = 0;
    virtual AnimalType GetType() const = 0;
    virtual ~Animal()
    {
        std::cout << "Animal::~Animal" << std::endl;
    }
};

struct Cat : public Animal
{
    std::string Noise() const override
    {
        return "meow";
    }

    AnimalType GetType() const override
    {
        return AnimalType::CAT;
    }

    ~Cat()
    {
        std::cout << "Cat::~Cat" << std::endl;
    }
};

struct Dog : public Animal
{
    std::string Noise() const override
    {
        return "woof";
    }

    AnimalType GetType() const override
    {
        return AnimalType::Dog;
    }

    ~Dog()
    {
        std::cout << "Dog::~Dog" << std::endl;
    }
};

struct Horse : public Animal
{
    std::string Noise() const override
    {
        return "neigh";
    }

    AnimalType GetType() const override
    {
        return AnimalType::HORSE;
    }

    ~Horse()
    {
        std::cout << "Horse::~Horse" << std::endl;
    }
};

person类的定义如下:

class Person
{
public:
    void ReactTo(Animal *animal);
    void ReactToV2(Animal *animal);

private:
    // using ReactFunction = void (Person::*)(Animal *);
    typedef void (Person::*ReactFunction)(Animal *);
    using ReactionHash = std::unordered_map<AnimalType, ReactFunction>;
    ReactionHash reactionHashes{
        {AnimalType::CAT, &Person::TryToPet},
        {AnimalType::Dog, &Person::RunAwayFrom},
        {AnimalType::HORSE, &Person::TryToRide}};
    void RunAwayFrom(Animal *animal);
    void TryToPet(Animal *animal);
    void TryToRide(Animal *animal);
};

void Person::RunAwayFrom(Animal *animal)
{
    std::cout << "Run away from " << typeid(*animal).name() << " " << animal->Noise() << std::endl;
};

void Person::TryToPet(Animal *animal)
{
    std::cout << "Try to Pet " << typeid(*animal).name() << " " << animal->Noise() << std::endl;
};

void Person::TryToRide(Animal *animal)
{
    std::cout << "Try to Ride " << typeid(*animal).name() << " " << animal->Noise() << std::endl;
};

在内部有一系列对不同动物的反应,比如如果Animal的类型是Dog,就RunWayFrom,如果是CAT就TryToPet,如果是Horse,就TryToRide。作为对比,这里定义了两个ReactTo方法,如果不使用成员函数指针,这ReactTo方法的实现可能是这样的:

void Person::ReactTo(Animal *animal)
{
    AnimalType aType = animal->GetType();
    if (aType == AnimalType::Dog)
    {
        RunAwayFrom(animal);
    }
    else if (aType == AnimalType::CAT)
    {
        TryToPet(animal);
    }
    else if (aType == AnimalType::HORSE)
    {
        TryToRide(animal);
    }
};

这一系列的“if else”就是“bad smell”。如果采用成员指针函数,这可以将不同的Animal类型和对它的操作存放到一个字典中。如下:

// using ReactFunction = void (Person::*)(Animal *);
typedef void (Person::*ReactFunction)(Animal *);
using ReactionHash = std::unordered_map<AnimalType, ReactFunction>;
ReactionHash reactionHashes{
        {AnimalType::CAT, &Person::TryToPet},
        {AnimalType::Dog, &Person::RunAwayFrom},
        {AnimalType::HORSE, &Person::TryToRide}};

在reactionHashes中,定义了每个不同的类型对应的操作。这样在ReactionV2中,就可以直接根据类型调用对应的函数了。

void Person::ReactToV2(Animal *animal)
{
    ReactFunction rf = reactionHashes[animal->GetType()];
    (this->*rf)(animal);
    //(*this.*rf)(animal);
};

可以看到,这里面消除了“if else”,我在起初不知道什么事成员函数指针的时候,直接使用rf(animal)来调用,结果编译的时候直接就报错了。

D:\Writing\pointer2member\main.cpp:131:7: error: must use '.*' or '->*' to call pointer-to-member function in 'rf (...)', e.g. '(... ->* rf) (...)'
  131 |     rf(animal);
      |     ~~^~~~~~~~

可以看到g++的提示还是非常清楚的,必须使用“.*”或者“->*”来调用成员函数,因为是成员函数,所以变量前面必须要有类型,这个类型显然就是当前的类的示例对象,那么就是this了。

接着上面的例子,main函数里的代码如下:

using AnimalCollection = std::vector<Animal *>;
int main()
{
    AnimalCollection pc;
    Cat c;
    Dog d;
    Horse h;
    pc.emplace_back(&c);
    pc.emplace_back(&d);
    pc.emplace_back(&h);
    Person p;
    for (auto a : pc)
    {
        p.ReactToV2(a);
    }
    return 0;
};

运行结果如下:

Try to Pet 3Cat meow
Run away from 3Dog woof
Try to Ride 5Horse neigh
Horse::~Horse
Animal::~Animal
Dog::~Dog
Animal::~Animal
Cat::~Cat
Animal::~Animal

 

 参考: