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

C++非虚函数声明的规则

2021-08-184.9k 阅读

C++ 非虚函数声明的规则

1. 基础概念与特性

在 C++ 中,非虚函数是指在类中声明时没有使用 virtual 关键字的成员函数。非虚函数具有一些特定的行为和规则,这些规则对于理解类的设计和代码的运行机制至关重要。

1.1 静态绑定

非虚函数遵循静态绑定机制。这意味着函数调用在编译时就已经确定,编译器根据调用对象的静态类型(即声明时的类型)来决定调用哪个函数。例如:

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

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

int main() {
    Base baseObj;
    Derived derivedObj;

    Base* basePtr = &derivedObj;
    basePtr->nonVirtualFunction(); // 调用 Base::nonVirtualFunction,因为静态类型是 Base*

    return 0;
}

在上述代码中,尽管 basePtr 指向一个 Derived 对象,但由于 nonVirtualFunction 是非虚函数,根据静态绑定,调用的是 Base 类中的 nonVirtualFunction

1.2 子类隐藏

当子类定义了与基类非虚函数同名的函数时,子类中的函数会隐藏基类中的函数。这种隐藏与重写(对于虚函数)不同,它不会改变函数的调用机制,依然是静态绑定。例如:

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

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

int main() {
    Derived derivedObj;
    derivedObj.nonVirtualFunction(10.5); // 调用 Derived::nonVirtualFunction
    // derivedObj.nonVirtualFunction(5);  // 编译错误,Base 类中的 nonVirtualFunction 被隐藏
    return 0;
}

在这个例子中,Derived 类中的 nonVirtualFunction(double num) 隐藏了 Base 类中的 nonVirtualFunction(int num)。因此,试图通过 Derived 对象调用 Base 类版本的 nonVirtualFunction(int num) 会导致编译错误。

2. 声明规则

2.1 访问控制

非虚函数的声明与其他成员函数一样,需要考虑访问控制。可以将非虚函数声明为 publicprotectedprivate

  • public 非虚函数:可以从类的外部通过类对象访问,通常用于提供类的外部接口。例如:
class Rectangle {
public:
    void setDimensions(int width, int height) {
        this->width = width;
        this->height = height;
    }
    int calculateArea() {
        return width * height;
    }
private:
    int width;
    int height;
};

int main() {
    Rectangle rect;
    rect.setDimensions(5, 10);
    int area = rect.calculateArea();
    std::cout << "Area of rectangle: " << area << std::endl;
    return 0;
}

Rectangle 类中,setDimensionscalculateArea 都是 public 非虚函数,允许外部代码操作 Rectangle 对象的状态并获取其属性。

  • protected 非虚函数:只能在类本身及其子类中访问,常用于提供一些内部辅助功能,子类可能需要重定义或调用。例如:
class Shape {
protected:
    void printInfo() {
        std::cout << "This is a shape." << std::endl;
    }
};

class Circle : public Shape {
public:
    void draw() {
        printInfo();
        std::cout << "Drawing a circle." << std::endl;
    }
};

int main() {
    Circle circle;
    circle.draw();
    // circle.printInfo();  // 编译错误,printInfo 是 protected
    return 0;
}

在这个例子中,Shape 类的 printInfo 函数是 protectedCircle 类可以在其成员函数 draw 中调用它,但外部代码不能直接调用。

  • private 非虚函数:只能在类内部访问,用于实现类的内部逻辑,不希望外部或子类直接调用。例如:
class String {
public:
    String(const char* str) {
        length = strlen(str);
        data = new char[length + 1];
        strcpy(data, str);
        validateData();
    }
private:
    void validateData() {
        if (data == nullptr) {
            std::cerr << "Invalid data." << std::endl;
        }
    }
    char* data;
    int length;
};

int main() {
    String str("Hello");
    // str.validateData();  // 编译错误,validateData 是 private
    return 0;
}

String 类中,validateDataprivate 非虚函数,仅在类的构造函数中调用以确保数据的有效性,外部代码无法访问。

2.2 函数重载

非虚函数可以像普通函数一样进行重载。重载是指在同一个作用域内定义多个同名函数,但它们的参数列表不同(参数个数、类型或顺序不同)。例如:

class MathUtils {
public:
    int add(int a, int b) {
        return a + b;
    }
    double add(double a, double b) {
        return a + b;
    }
    int add(int a, int b, int c) {
        return a + b + c;
    }
};

int main() {
    MathUtils utils;
    int result1 = utils.add(2, 3);
    double result2 = utils.add(2.5, 3.5);
    int result3 = utils.add(2, 3, 4);
    std::cout << "Int add result: " << result1 << std::endl;
    std::cout << "Double add result: " << result2 << std::endl;
    std::cout << "Three int add result: " << result3 << std::endl;
    return 0;
}

MathUtils 类中,有三个 add 函数,它们根据参数的不同进行了重载,编译器会根据调用时提供的参数类型和个数来选择合适的函数。

2.3 常量成员函数

非虚函数可以声明为常量成员函数,通过在函数声明后加上 const 关键字。常量成员函数承诺不会修改对象的成员变量(除非这些成员变量被声明为 mutable)。例如:

class Point {
public:
    Point(int x, int y) : x(x), y(y) {}
    int getX() const {
        return x;
    }
    int getY() const {
        return y;
    }
    void move(int newX, int newY) {
        x = newX;
        y = newY;
    }
private:
    int x;
    int y;
};

int main() {
    const Point p(10, 20);
    int x = p.getX();
    int y = p.getY();
    // p.move(15, 25);  // 编译错误,p 是 const 对象,不能调用非 const 成员函数 move
    std::cout << "X: " << x << ", Y: " << y << std::endl;
    return 0;
}

Point 类中,getXgetY 是常量成员函数,它们可以被 const 对象调用。而 move 是非常量成员函数,不能被 const 对象调用。

2.4 内联非虚函数

非虚函数可以声明为内联函数,通过在函数声明前加上 inline 关键字。内联函数的目的是减少函数调用的开销,编译器会尝试将函数体直接插入到调用处。例如:

class SmallMath {
public:
    inline int square(int num) {
        return num * num;
    }
};

int main() {
    SmallMath math;
    int result = math.square(5);
    std::cout << "Square of 5: " << result << std::endl;
    return 0;
}

SmallMath 类中,square 函数被声明为内联函数。对于简单的函数,如这个计算平方的函数,使用内联可以提高性能,尤其是在频繁调用的情况下。不过,编译器并不一定会按照程序员的意愿将函数内联,它会根据函数的复杂性、优化设置等因素来决定。

3. 设计与使用原则

3.1 不变性与稳定性

非虚函数通常用于表示类的一些稳定的、不希望在子类中被改变语义的行为。例如,在一个 Date 类中,计算日期距离某个固定日期的天数的函数可能是一个非虚函数,因为这个计算逻辑应该是固定的,不应该被子类随意更改。

class Date {
public:
    Date(int year, int month, int day) : year(year), month(month), day(day) {}
    int daysSinceEpoch() const {
        // 这里省略具体的日期计算逻辑
        return 0;
    }
private:
    int year;
    int month;
    int day;
};

class HistoricalDate : public Date {
public:
    HistoricalDate(int year, int month, int day) : Date(year, month, day) {}
    // 不应该重写 daysSinceEpoch,因为它的逻辑在基类中已经确定
};

在这个例子中,daysSinceEpoch 是非虚函数,它提供了一个稳定的日期计算功能,子类 HistoricalDate 不应该重写它,以保证日期计算逻辑的一致性。

3.2 代码复用

非虚函数有助于实现代码复用。基类中的非虚函数可以被子类继承并直接使用,子类无需重新实现相同的功能。例如,在一个图形绘制库中,Shape 类可能有一个非虚函数 drawBorder 用于绘制图形的边框,子类 RectangleCircle 可以直接使用这个函数而无需重新编写边框绘制逻辑。

class Shape {
public:
    void drawBorder() {
        std::cout << "Drawing border of shape." << std::endl;
    }
};

class Rectangle : public Shape {
public:
    void draw() {
        drawBorder();
        std::cout << "Drawing rectangle." << std::endl;
    }
};

class Circle : public Shape {
public:
    void draw() {
        drawBorder();
        std::cout << "Drawing circle." << std::endl;
    }
};

int main() {
    Rectangle rect;
    Circle circle;
    rect.draw();
    circle.draw();
    return 0;
}

在这个例子中,RectangleCircle 类继承了 Shape 类的 drawBorder 非虚函数,实现了代码复用。

3.3 避免过度隐藏

在设计子类时,要谨慎考虑是否需要定义与基类非虚函数同名的函数,以避免过度隐藏基类的功能。如果确实需要在子类中提供不同的行为,应优先考虑使用虚函数来实现多态行为。例如:

class Animal {
public:
    void makeSound() {
        std::cout << "Animal makes a sound." << std::endl;
    }
};

class Dog : public Animal {
public:
    void makeSound() {
        std::cout << "Dog barks." << std::endl;
    }
};

int main() {
    Animal* animalPtr = new Dog();
    animalPtr->makeSound(); // 调用 Animal::makeSound,因为 makeSound 是非虚函数
    delete animalPtr;
    return 0;
}

在这个例子中,如果希望 animalPtr 能根据实际对象类型调用正确的 makeSound 函数,makeSound 应该声明为虚函数。否则,使用非虚函数会导致预期外的行为,因为非虚函数是静态绑定的。

4. 与虚函数的对比

理解非虚函数与虚函数的区别对于正确使用它们至关重要。

4.1 绑定机制

如前文所述,非虚函数使用静态绑定,在编译时确定调用的函数;而虚函数使用动态绑定,在运行时根据对象的实际类型确定调用的函数。例如:

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

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

int main() {
    Base* basePtr = new Derived();
    basePtr->virtualFunction(); // 调用 Derived::virtualFunction,动态绑定
    basePtr->nonVirtualFunction(); // 调用 Base::nonVirtualFunction,静态绑定
    delete basePtr;
    return 0;
}

在这个例子中,通过 basePtr 调用 virtualFunction 时,由于动态绑定,实际调用的是 Derived 类中的 virtualFunction;而调用 nonVirtualFunction 时,根据静态绑定,调用的是 Base 类中的 nonVirtualFunction

4.2 设计意图

虚函数主要用于实现多态性,允许子类根据自身需求重写函数行为,以实现不同的具体实现。而非虚函数更侧重于提供稳定的、不变的功能,子类通常不应该重写它们。例如,在一个游戏开发框架中,GameObject 类的 updatePosition 函数可能是非虚函数,因为更新位置的基本逻辑对于大多数游戏对象是一致的;而 render 函数可能是虚函数,因为不同类型的游戏对象(如角色、道具等)有不同的渲染方式。

class GameObject {
public:
    void updatePosition(float deltaX, float deltaY) {
        positionX += deltaX;
        positionY += deltaY;
    }
    virtual void render() {
        std::cout << "Rendering a generic game object." << std::endl;
    }
private:
    float positionX;
    float positionY;
};

class Character : public GameObject {
public:
    void render() override {
        std::cout << "Rendering a character." << std::endl;
    }
};

class Item : public GameObject {
public:
    void render() override {
        std::cout << "Rendering an item." << std::endl;
    }
};

在这个例子中,updatePosition 是非虚函数,提供了通用的位置更新逻辑;而 render 是虚函数,子类 CharacterItem 可以重写它以实现各自的渲染逻辑。

4.3 性能影响

由于虚函数使用动态绑定,需要通过虚函数表(vtable)来查找实际要调用的函数,这会带来一定的性能开销。而非虚函数由于是静态绑定,在编译时就确定了调用关系,通常性能更好。然而,在现代编译器的优化下,这种性能差异在很多情况下并不显著,而且在实际应用中,设计的正确性和可维护性往往比微小的性能差异更重要。

5. 常见错误与陷阱

5.1 意外隐藏

如前面提到的,子类定义与基类非虚函数同名的函数时会导致基类函数被隐藏。如果不小心这样做,可能会导致代码行为不符合预期。例如:

class Base {
public:
    void print(int num) {
        std::cout << "Base: " << num << std::endl;
    }
};

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

int main() {
    Derived derivedObj;
    derivedObj.print(10.5); // 调用 Derived::print
    // derivedObj.print(5);  // 编译错误,Base::print 被隐藏
    return 0;
}

在这个例子中,如果程序员期望通过 Derived 对象调用 Base 类的 print(int num) 函数,就会遇到编译错误,因为 Derived 类的 print(double num) 隐藏了它。为了避免这种情况,可以使用 using 声明将基类函数引入子类作用域:

class Base {
public:
    void print(int num) {
        std::cout << "Base: " << num << std::endl;
    }
};

class Derived : public Base {
public:
    using Base::print;
    void print(double num) {
        std::cout << "Derived: " << num << std::endl;
    }
};

int main() {
    Derived derivedObj;
    derivedObj.print(10.5); // 调用 Derived::print
    derivedObj.print(5); // 调用 Base::print
    return 0;
}

通过 using Base::printBase 类的 print 函数被引入到 Derived 类作用域,避免了意外隐藏。

5.2 混淆非虚与虚函数的行为

有时候程序员可能会在应该使用虚函数的地方使用了非虚函数,导致多态行为无法实现。例如,在一个图形绘制的继承体系中:

class Shape {
public:
    void draw() {
        std::cout << "Drawing a shape." << std::endl;
    }
};

class Circle : public Shape {
public:
    void draw() {
        std::cout << "Drawing a circle." << std::endl;
    }
};

class Rectangle : public Shape {
public:
    void draw() {
        std::cout << "Drawing a rectangle." << std::endl;
    }
};

int main() {
    Shape* shapes[2];
    shapes[0] = new Circle();
    shapes[1] = new Rectangle();
    for (int i = 0; i < 2; ++i) {
        shapes[i]->draw(); // 都会调用 Shape::draw,因为 draw 是非虚函数
    }
    for (int i = 0; i < 2; ++i) {
        delete shapes[i];
    }
    return 0;
}

在这个例子中,如果希望根据实际对象类型调用不同的 draw 函数,draw 应该声明为虚函数。否则,由于静态绑定,所有的调用都会指向 Shape 类的 draw 函数。

5.3 违反 Liskov 替换原则

Liskov 替换原则指出,子类对象应该能够替换其父类对象,而程序的行为不会因此发生改变。如果在子类中对非虚函数进行了不恰当的重定义(隐藏),可能会违反这个原则。例如:

class Stack {
public:
    void push(int value) {
        // 假设这里有实际的栈操作逻辑
        std::cout << "Pushing " << value << " onto the stack." << std::endl;
    }
    int pop() {
        // 假设这里有实际的栈操作逻辑
        return 0;
    }
};

class SpecialStack : public Stack {
public:
    void push(int value) {
        if (value < 0) {
            std::cerr << "SpecialStack does not allow negative values." << std::endl;
        } else {
            Stack::push(value);
        }
    }
};

void processStack(Stack& stack) {
    stack.push(5);
    stack.push(-3); // 在 SpecialStack 中会输出错误信息,违反 Liskov 替换原则
}

int main() {
    Stack stack;
    SpecialStack specialStack;
    processStack(stack);
    processStack(specialStack);
    return 0;
}

在这个例子中,SpecialStack 重定义了 Stack 的非虚函数 push,导致 processStack 函数在处理 SpecialStack 对象时行为与处理 Stack 对象时不同,违反了 Liskov 替换原则。如果 push 是虚函数,SpecialStack 重写 push 就符合多态和 Liskov 替换原则的设计。

6. 总结与最佳实践

在 C++ 编程中,正确声明和使用非虚函数对于构建健壮、高效且易于维护的代码至关重要。以下是一些总结和最佳实践:

  1. 明确设计意图:在决定将函数声明为非虚函数时,要确保其行为是稳定的,不希望被子类改变。如果需要子类有不同的实现,应优先考虑虚函数。
  2. 注意访问控制:合理设置非虚函数的访问级别(publicprotectedprivate),以控制对函数的访问,保护类的内部实现。
  3. 避免意外隐藏:在子类中定义与基类非虚函数同名的函数时要谨慎,使用 using 声明来避免意外隐藏基类函数。
  4. 权衡性能与设计:虽然非虚函数在性能上通常比虚函数略优,但不要仅仅为了微小的性能提升而牺牲设计的清晰性和可维护性。
  5. 遵循 Liskov 替换原则:确保子类对非虚函数的使用符合 Liskov 替换原则,避免因不恰当的重定义导致代码行为异常。

通过遵循这些规则和最佳实践,开发者能够更好地利用非虚函数的特性,编写出高质量的 C++ 代码。同时,深入理解非虚函数与虚函数的区别和使用场景,也有助于在复杂的面向对象设计中做出正确的决策。