MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C++非虚函数的使用场景

2021-10-206.0k 阅读

C++ 非虚函数的基本概念

非虚函数定义与特性

在 C++ 中,非虚函数是类成员函数的一种,它在编译时就确定了具体调用的函数版本。与虚函数不同,非虚函数的调用不依赖于对象的实际类型,而是取决于指针或引用的静态类型。这意味着无论对象在运行时的实际类型是什么,只要通过特定类型的指针或引用调用非虚函数,就会调用该类型定义的非虚函数版本。

例如,我们有以下简单的类层次结构:

class Base {
public:
    void nonVirtualFunction() {
        std::cout << "Base::nonVirtualFunction" << std::endl;
    }
};

class Derived : public Base {
public:
    void nonVirtualFunction() {
        std::cout << "Derived::nonVirtualFunction" << std::endl;
    }
};

在上述代码中,Base 类和 Derived 类都定义了 nonVirtualFunction 函数,它们是两个完全独立的函数,不存在多态性的关联。

当我们进行如下调用时:

int main() {
    Base* basePtr = new Derived();
    basePtr->nonVirtualFunction();
    delete basePtr;
    return 0;
}

输出结果将是 Base::nonVirtualFunction。因为 basePtr 的静态类型是 Base*,编译器在编译时就确定了调用 Base 类的 nonVirtualFunction 函数。

与虚函数的对比

虚函数则相反,它支持运行时多态性。当通过基类指针或引用调用虚函数时,实际调用的函数版本取决于对象的实际类型。继续以上面的类层次结构为例,如果将 nonVirtualFunction 改为虚函数:

class Base {
public:
    virtual void virtualFunction() {
        std::cout << "Base::virtualFunction" << std::endl;
    }
};

class Derived : public Base {
public:
    void virtualFunction() override {
        std::cout << "Derived::virtualFunction" << std::endl;
    }
};

main 函数中进行如下调用:

int main() {
    Base* basePtr = new Derived();
    basePtr->virtualFunction();
    delete basePtr;
    return 0;
}

此时输出结果将是 Derived::virtualFunction。因为 virtualFunction 是虚函数,运行时根据 basePtr 所指向对象的实际类型(Derived)来确定调用的函数版本。

这种差异使得虚函数和非虚函数在使用场景上有明显的不同,接下来我们将详细探讨非虚函数的使用场景。

封装不变部分的逻辑

确保统一的行为

在一些情况下,我们希望类的所有派生类都遵循某种固定的行为逻辑,这种逻辑不应该被派生类随意改变。这时非虚函数就非常适用。

例如,考虑一个图形类 Shape,它有一个计算面积的函数 calculateArea。对于所有的图形,计算面积前可能都需要进行一些初始化操作,比如检查图形的有效性等。我们可以将这些初始化操作放在一个非虚函数中,而具体的面积计算逻辑放在虚函数中。

class Shape {
private:
    bool isValid;
public:
    Shape() : isValid(true) {}
    double calculateArea() {
        if (!isValid) {
            std::cerr << "Shape is not valid for area calculation." << std::endl;
            return 0.0;
        }
        return doCalculateArea();
    }
private:
    virtual double doCalculateArea() const = 0;
};

class Circle : public Shape {
private:
    double radius;
public:
    Circle(double r) : radius(r) {}
private:
    double doCalculateArea() const override {
        return 3.14159 * radius * radius;
    }
};

class Rectangle : public Shape {
private:
    double length;
    double width;
public:
    Rectangle(double l, double w) : length(l), width(w) {}
private:
    double doCalculateArea() const override {
        return length * width;
    }
};

在上述代码中,Shape 类的 calculateArea 函数是非虚函数,它确保了在计算面积前检查图形有效性的统一行为。而具体的面积计算逻辑由虚函数 doCalculateArea 来实现,不同的派生类(CircleRectangle)可以根据自身特点重写该虚函数。

提供基础实现且不允许修改

有时候,基类提供的某些功能是基础且通用的,不希望派生类改变其核心逻辑。比如一个日志记录类 Logger,它有一个基本的记录日志到文件的功能,并且这个功能不应该被派生类随意修改。

#include <iostream>
#include <fstream>
#include <string>

class Logger {
public:
    void logMessage(const std::string& message) {
        std::ofstream file("log.txt", std::ios::app);
        if (file.is_open()) {
            file << message << std::endl;
            file.close();
        } else {
            std::cerr << "Unable to open log file." << std::endl;
        }
    }
};

class SpecialLogger : public Logger {
public:
    // 派生类不能改变 logMessage 的核心文件记录逻辑
    void logSpecialMessage(const std::string& message) {
        std::string prefix = "[Special] ";
        std::string fullMessage = prefix + message;
        logMessage(fullMessage);
    }
};

在这个例子中,Logger 类的 logMessage 函数是非虚函数,它提供了将消息记录到文件的基础功能。SpecialLogger 类继承自 Logger 类,并通过调用 logMessage 来实现更特殊的日志记录功能,但不能改变 logMessage 的核心文件记录逻辑。

实现内部接口

隐藏实现细节

非虚函数可以作为类的内部接口,将类的实现细节隐藏起来,只对外提供统一的调用方式。

以一个简单的栈类 Stack 为例,它内部使用数组来实现栈的存储。我们可以通过非虚函数来提供栈的基本操作,如压栈、弹栈等,而将数组操作等实现细节隐藏在这些非虚函数内部。

class Stack {
private:
    int* stackArray;
    int top;
    int capacity;
public:
    Stack(int cap) : capacity(cap), top(-1) {
        stackArray = new int[capacity];
    }
    ~Stack() {
        delete[] stackArray;
    }
    void push(int value) {
        if (top == capacity - 1) {
            std::cerr << "Stack overflow." << std::endl;
            return;
        }
        stackArray[++top] = value;
    }
    int pop() {
        if (top == -1) {
            std::cerr << "Stack underflow." << std::endl;
            return -1;
        }
        return stackArray[top--];
    }
};

在上述代码中,pushpop 函数作为 Stack 类的非虚内部接口,外部使用者只需要调用这两个函数来操作栈,而不需要了解栈内部数组的具体实现细节。

提供稳定的内部调用接口

在类的层次结构中,基类可能需要为派生类提供一些稳定的内部调用接口,这些接口的实现不应该被派生类修改,以保证整个类体系的一致性。

例如,一个游戏角色类 Character 有一些基本的动作,如移动。而不同类型的角色(如战士、法师等)在移动时可能有一些特殊的表现,但都需要先执行一些通用的移动前准备操作。

class Character {
public:
    void move(int x, int y) {
        prepareForMove();
        doMove(x, y);
    }
private:
    void prepareForMove() {
        std::cout << "Character is preparing to move." << std::endl;
    }
    virtual void doMove(int x, int y) = 0;
};

class Warrior : public Character {
private:
    virtual void doMove(int x, int y) override {
        std::cout << "Warrior is moving to (" << x << ", " << y << ")." << std::endl;
    }
};

class Mage : public Character {
private:
    virtual void doMove(int x, int y) override {
        std::cout << "Mage is teleporting to (" << x << ", " << y << ")." << std::endl;
    }
};

在这个例子中,Character 类的 move 函数是非虚函数,它调用了 prepareForMove 这个非虚的内部准备函数和虚函数 doMove。派生类 WarriorMage 只需要重写 doMove 函数来实现各自的移动逻辑,而不能改变 prepareForMovemove 的基本调用流程,从而保证了整个角色移动逻辑的一致性。

性能优化方面的应用

避免虚函数调用开销

虚函数调用涉及到额外的开销,主要包括通过虚函数表指针查找虚函数地址等操作。在一些对性能要求极高的场景中,如果不需要多态性,使用非虚函数可以避免这些开销。

例如,在一个实时图形渲染系统中,有一个 Vertex 类用于表示图形的顶点。顶点有一些基本的操作,如设置坐标等,这些操作在运行时不会因为对象的实际类型而改变。

class Vertex {
private:
    float x, y, z;
public:
    void setCoordinates(float newX, float newY, float newZ) {
        x = newX;
        y = newY;
        z = newZ;
    }
    // 其他非虚的顶点操作函数
};

在这个 Vertex 类中,setCoordinates 函数是非虚函数。由于在图形渲染过程中,大量的顶点对象会频繁调用这个函数,如果使用虚函数,虚函数调用的开销会在一定程度上影响渲染性能。使用非虚函数可以提高函数调用的效率,从而提升整个系统的性能。

利于编译器优化

编译器对于非虚函数的调用有更多的优化机会。因为非虚函数在编译时就确定了具体调用的函数版本,编译器可以进行内联展开等优化操作,进一步提高性能。

继续以上面的 Stack 类为例,假设编译器支持内联优化,对于 pushpop 这样的非虚函数,编译器可以将函数体直接插入到调用处,避免了函数调用的栈操作开销。

class Stack {
private:
    int* stackArray;
    int top;
    int capacity;
public:
    Stack(int cap) : capacity(cap), top(-1) {
        stackArray = new int[capacity];
    }
    ~Stack() {
        delete[] stackArray;
    }
    inline void push(int value) {
        if (top == capacity - 1) {
            std::cerr << "Stack overflow." << std::endl;
            return;
        }
        stackArray[++top] = value;
    }
    inline int pop() {
        if (top == -1) {
            std::cerr << "Stack underflow." << std::endl;
            return -1;
        }
        return stackArray[top--];
    }
};

在上述代码中,通过 inline 关键字提示编译器对 pushpop 函数进行内联优化。编译器在编译时如果条件允许,会将 pushpop 函数的代码直接插入到调用处,从而提高程序的执行效率。

继承体系中的框架构建

定义框架行为

在构建一个类的继承体系作为框架时,非虚函数可以用来定义框架的基本行为和调用流程,而派生类在遵循框架规则的前提下进行扩展。

例如,一个基于事件驱动的游戏开发框架,有一个基类 GameEntity 表示游戏中的实体。GameEntity 类有一个处理事件的函数 handleEvent,它定义了处理事件的基本流程。

class Event {
    // 事件相关的定义
};

class GameEntity {
public:
    void handleEvent(const Event& event) {
        preHandleEvent();
        doHandleEvent(event);
        postHandleEvent();
    }
private:
    void preHandleEvent() {
        std::cout << "Preparing to handle event for GameEntity." << std::endl;
    }
    virtual void doHandleEvent(const Event& event) = 0;
    void postHandleEvent() {
        std::cout << "Finished handling event for GameEntity." << std::endl;
    }
};

class Player : public GameEntity {
private:
    virtual void doHandleEvent(const Event& event) override {
        std::cout << "Player is handling the event." << std::endl;
    }
};

class Enemy : public GameEntity {
private:
    virtual void doHandleEvent(const Event& event) override {
        std::cout << "Enemy is handling the event." << std::endl;
    }
};

在这个例子中,GameEntity 类的 handleEvent 函数是非虚函数,它定义了处理事件的框架流程,包括预处理、具体处理和后处理。派生类 PlayerEnemy 只需要重写 doHandleEvent 函数来实现各自对事件的具体处理逻辑,而不能改变整个处理事件的框架流程。

约束派生类行为

非虚函数还可以用来约束派生类的行为,确保派生类按照框架的要求进行实现。

比如,在一个图形绘制框架中,有一个 Drawable 基类表示可绘制的对象。Drawable 类有一个非虚函数 draw,它规定了绘制对象的基本步骤,并且要求派生类必须按照这个步骤来实现具体的绘制逻辑。

class GraphicsContext {
    // 图形上下文相关的定义
};

class Drawable {
public:
    void draw(GraphicsContext& context) {
        setupContext(context);
        doDraw(context);
        teardownContext(context);
    }
private:
    void setupContext(GraphicsContext& context) {
        std::cout << "Setting up graphics context for drawing." << std::endl;
    }
    virtual void doDraw(GraphicsContext& context) = 0;
    void teardownContext(GraphicsContext& context) {
        std::cout << "Tearing down graphics context after drawing." << std::endl;
    }
};

class RectangleDrawable : public Drawable {
private:
    virtual void doDraw(GraphicsContext& context) override {
        std::cout << "Drawing a rectangle." << std::endl;
    }
};

class CircleDrawable : public Drawable {
private:
    virtual void doDraw(GraphicsContext& context) override {
        std::cout << "Drawing a circle." << std::endl;
    }
};

在上述代码中,Drawable 类的 draw 函数是非虚函数,它规定了绘制对象的基本流程。派生类 RectangleDrawableCircleDrawable 必须遵循这个流程,通过重写 doDraw 函数来实现具体的绘制逻辑,从而保证了整个图形绘制框架的一致性和规范性。

与模板元编程结合的应用

利用非虚函数实现编译期多态

在模板元编程中,非虚函数可以与模板结合,实现编译期多态。这种方式利用了模板实例化的特性,在编译时根据不同的模板参数选择不同的函数版本,而不需要运行时的虚函数机制。

例如,我们有一个计算两个数之和的模板函数,根据不同的数据类型,我们可以通过非虚函数实现不同的加法逻辑。

class IntAdder {
public:
    int add(int a, int b) {
        return a + b;
    }
};

class DoubleAdder {
public:
    double add(double a, double b) {
        return a + b;
    }
};

template <typename AdderType, typename T1, typename T2>
T1 add(T1 a, T2 b) {
    AdderType adder;
    return adder.add(a, b);
}

在上述代码中,IntAdderDoubleAdder 类中的 add 函数是非虚函数。通过模板函数 add,我们可以在编译时根据传入的 AdderType 选择不同的加法逻辑。例如:

int main() {
    int result1 = add<IntAdder>(3, 5);
    double result2 = add<DoubleAdder>(3.5, 5.5);
    return 0;
}

在这个例子中,编译时会根据模板参数 IntAdderDoubleAdder 分别实例化不同版本的 add 函数,实现了编译期多态,而不需要虚函数的运行时开销。

模板元编程中的代码复用

非虚函数在模板元编程中还可以用于代码复用。通过将一些通用的逻辑封装在非虚函数中,不同的模板实例可以共享这些逻辑。

比如,我们有一个模板类 Matrix 用于表示矩阵,并且有一些矩阵操作函数。其中,矩阵乘法的部分逻辑可以封装在非虚函数中供不同类型的矩阵使用。

template <typename T>
class Matrix {
private:
    T** data;
    int rows;
    int cols;
public:
    Matrix(int r, int c) : rows(r), cols(c) {
        data = new T*[rows];
        for (int i = 0; i < rows; ++i) {
            data[i] = new T[cols];
        }
    }
    ~Matrix() {
        for (int i = 0; i < rows; ++i) {
            delete[] data[i];
        }
        delete[] data;
    }
    void multiply(const Matrix<T>& other, Matrix<T>& result) {
        for (int i = 0; i < rows; ++i) {
            for (int j = 0; j < other.cols; ++j) {
                result.data[i][j] = 0;
                for (int k = 0; k < cols; ++k) {
                    result.data[i][j] += data[i][k] * other.data[k][j];
                }
            }
        }
    }
};

在上述 Matrix 模板类中,multiply 函数是非虚函数,它实现了矩阵乘法的通用逻辑。不同类型(如 Matrix<int>Matrix<double> 等)的矩阵实例都可以复用这个函数,通过模板实例化在编译时生成针对不同数据类型的矩阵乘法代码,提高了代码的复用性。

综上所述,C++ 非虚函数在封装不变逻辑、实现内部接口、性能优化、继承体系框架构建以及与模板元编程结合等方面都有着广泛而重要的应用场景。合理使用非虚函数可以使代码更加清晰、高效且易于维护。在实际编程中,我们需要根据具体的需求和场景,准确地选择使用非虚函数还是虚函数,以充分发挥 C++ 的强大功能。