21. 图形的面积

在刷题过程中面向对象使用的比较少,但是工程化开发中。面向对象却是很重要的内容,本节课我们借助一道题目学习 C++ 中面向对象的一些知识,你也可以使用简易的方法轻松的解决本道题目,但是我们更希望你对类和面向对象中的三大特性:封装、继承、多态面向对象中的三大特性:封装、继承、多态有更多的了解。

类和面向对象

C++ 是一种多范式编程语言,支持过程编程和面向对象编程,它将数据和操作数据的方法组织为类和对象。

这里的“对象”实际上是对现实世界中所存在的事物的一种抽象,举个例子,你在计算机世界中怎么表示“人”这个概念呢?

我们可以将之抽象为一个类Person, 并具有一些“属性”和“方法”。

  • “属性”表示Person类所具有的特征,比如姓名、年龄、性别,通过这些特征,我们可以描述一个“人”的基本状态。

  • “方法”表示Person类的行为和功能,比如吃饭、睡觉、行走,通过这些动作,我们可以描述一个“人”的动态行为。

比如下面的“伪代码”表示Person类的定义, 它包括姓名、性别、年龄和吃饭的方法。

Person {
    // 姓名、性别、年龄等属性
    name;
    gender;
    age;
      // 吃饭的方法
      eat() {
    }
    // 行走的方法
    walk() {
    
    }
}

这样在计算机世界,一个“人”的概念就建立起来了,但这里的Person类只是个“模具”,空有概念,而无法表示具体的某一个人,只有创造一个类的实例(也就是我们说的对象),比如"张三,18, 男", "李明、20,男",才能真正的使用。

现在你是不是能对面向对象有一些初步的理解了呢?

简而言之,“类”是现实世界中的实体在计算机世界中的抽象概念,类可以看作是对象的模板,它定义了对象的结构和行为方式,可以用来创建具有相同属性和行为的多个对象,而对象是“类”的实现。

了解了概念之后,我们先了解一下类的基本写法:

class 类名{
访问修饰符:
          // 成员变量,表示类的属性, 定义方式和变量的定义一样
      // 成员方法,表示类的行为, 定义方式和方法的定义一样
}; // 分号结束一个类

C++使用class定义一个类,并在类中定义成员变量和成员方法。

访问修饰符指定了成员变量和成员方法的可见性和访问权限。常用的修饰符包括 private(私有)public(公有)protected(受保护)

  • public: 被修饰的成员在类的内部、派生类(子类)的内部和类的对象外部都可以访问。

  • private: 被修饰的成员只能在定义该成员的类的内部访问。

  • protected: 被修饰的成员只能在定义该成员的类的内部以及派生类汇总访问。

比如下面的示例,public定义成员变量和成员方法的权限,后面定义了成员变量myAttribute和成员方法myMethod

class MyClass {
public:
    // 成员变量
    int myAttribute;

    // 成员方法
    void myMethod() {
        // 方法实现
    }
};

int main() {
    // 创建对象
    MyClass obj;

    // 访问属性
    obj.myAttribute = 42;

    // 调用方法
    obj.myMethod();

    return 0;
}

封装

封装是面向对象的三大特性之一,封装的主要目的是为了保证数据的安全性,我们假设有一个Circle类,它具有半径这个属性。

class Circle {
public:
    // 成员变量
    int radius;
};

然后我们创建一个圆对象

Circle circe; // 创建一个对象

创建对象之后,外部代码可以直接访问和修改半径,甚至将其设置为负数,这样的设计显然是不合理的。

// 外部代码没有经过验证可以直接访问和修改半径
circle.radius = 10

为了防止这些问题的发生,我们可以通过封装隐藏对象中一些不希望被外部所访问到的属性或方法,具体怎么做呢?可以分为两步:

  • 将对象的属性名,设置为private,只能被类所访问

  • 提供公共的getset方法来获取和设置对象的属性

class Circle {
// 私有属性和方法
private:
    int radius;  // 将圆的半径设置为私有的
// 公有属性和方法
public:
      // setXX方法设置属性
    void setRadius(int r) {
          radius = r;
    }
        // getXXX方法获取属性
    int getRadius() {
        return radius;
    }
};

使用封装,我们隐藏了类的一些属性,具体的做法是使用get方法获取属性,使用set方法设置属性,如果希望属性是只读的,则可以直接去掉set方法,如果希望属性不能被外部访问,则可以直接去掉get方法。此外我们还可以在读取属性和修改属性的同时做一些其他的处理,比如如下的操作:

void setRadius(int r) {
      // 对输入的半径进行验证,只有半径大于0,才进行处理
    if (r >= 0) {
        radius = r;
    } else {
        cout << "半径不能为负数" << endl;
    }
}

构造函数

在学习继承之前,我们先来复习一下构造函数的知识,类的构造函数和之前结构体的构造函数类似,用于初始化对象的成员变量,构造函数与类同名,没有返回类型,并且在对象创建时自动调用。其基本语法包括:

  • 函数名:与类名相同

  • 参数列表:可以有零个或多个参数,用于在创建对象时传递初始化信息。

  • 函数体: 用于执行构造函数的初始化逻辑。

下面我们还以Person类作为示例,包含一个默认构造函数和带参数的构造函数。

const string& personName表示对string类型对常量引用,你可以传递字符串参数,但是不能在函数中修改这个参数的值。

class Person {
private:
  int age;
  string name;
public:
  // 默认构造函数
  Person() {
    age = 20;
    name = "Tom"
  }
  // 带参数的构造函数
  Person(int personAge, const string& personName) {
    age = personAge;
    name = personName
  }
}
int main() {
    // 使用默认构造函数创建对象
    Person person1;
 
    // 使用带参数的构造函数创建对象
    Person person2(20, "Jerry");
  
    return 0;
}

此外,还有构造函数的成员初始化列表写法,这种写法允许在进入构造函数主体之前对类成员进行初始化,比如下面的示例。

Person(int personAge, const string& personName) : age(personAge), name(personName) {
    
}

在上面的代码中, Person 类的构造函数接受一个 string 类型的参数 persnName和一个int类型的参数personAge,并通过成员初始化列表初始化了 成员变量。在这里,: age(personAge), name(personName) 表示将 personAge 的值赋给 age 成员变量, 将 personName 的值赋给 name , 从而完成了成员变量初始化。

继承

在对象中,总有一些操作是重复的,比如说Person类具有姓名、身高、年龄等特征,并具有一些行走、吃饭、睡觉的方法,而我们要实现一个Teacher类,Teacher首先也是一个人,他也具备人的特征和方法,那我们是不是也应该用代码去实现这些特征和方法呢,这就势必会产生一些重复的代码。

因此,我们可以采用“继承”的方式使得一个类获取到其他类中的属性和方法。在定义类时,可以在类名后指定当前类的父类(超类), 子类可以直接继承父类中的所有属性和方法,从而避免编写重复性的代码,此外我们还可以对子类进行扩展。

假设,我们有一个图形类Shape, 它具有一个属性和一个方法,属性为类型,方法为求图形的面积

class Shape {
protected:
    string type;  // 形状类型

public:
    // 构造函数
    Shape(const string& shapeType) : type(shapeType) {}

    // 求面积的函数
    double getArea() const {
        return 0.0;
    }

    // 获取形状类型
    string getType() const {
        return type;
    }
};

在上面的代码中,getArea函数使用const用来修饰,是用来表示该函数不会修改对象的状态,使用const能保证对对象的访问是安全的。

double getArea() const {
      // 不会修改对象的状态
      return 0.0;
}

要想实现继承,我们还需要一个关于圆的类Circle,它继承自Shape

// Circle 类,继承自 Shape
class Circle : public Shape {
private:
    int radius;  // 圆的半径

public:
    // 构造函数, 调用Shape的构造函数,初始化了类型为"circle"
    Circle(int circleRadius) : Shape("Circle"), radius(circleRadius) {}

    // 重写基类的方法
    double calculateArea() const override{
        return 3.14 * radius * radius;  // 圆的面积公式
    }

    // 获取半径
    int getRadius() const {
        return radius;
    }
};

在上面的示例代码中,图形类拥有shape属性和getArea、getType方法,而子类在父类这些属性和方法的基础上新增了radius属性和getRadius方法,并且在子类和父类中都有getArea这个方法,这被称为方法的重写,方法的重写需要override关键字,其意思是子类重写父类的方法,并提供自己的实现。

多态

多态常常和继承紧密相连,它允许不同的对象使用相同的接口进行操作,但在运行时表现出不同的行为。多态性使得可以使用基类类型的指针或引用来引用派生类的对象,从而在运行时选择调用相应的派生类方法。

C++中实现多态性的方法是通过virtual虚函数,比如下面的示例:

class Shape {
public:
    virtual double calculateArea() const = 0;
};
class Circle : public Shape {
private:
    int radius;

public:
    double calculateArea() const override {
        return 3.14 * radius * radius;
    }
};
class Rectangle : public Shape {
private:
    int width;
    int height;

public:
    // 构造函数,用于初始化 width 和 height
    Rectangle(int w, int h) : width(w), height(h) {}
    // width * height 的结果是整数,但 calculateArea 方法的返回类型是 double
    // 为了确保结果是一个浮点数,使用 static_cast<double> 将其显式转换为 double 类型
       double calculateArea() const override {
        return static_cast<double>(width * height);
    }
};

这里使用virtual在父类中定义了一个虚函数,而= 0表示这是一个纯虚函数,即定义的函数在基类中没有实现,但是要求它的派生类都必须提供这个函数的实现,这种抽象的方法使得 Shape 类成为一个抽象基类,不能被实例化,只能被用作派生其他类的基类。

然后两个派生类 CircleRectangle则是重写了 calculateArea 方法,它们提供了各自的实现,有着不同的计算逻辑。

int main() {
    std::vector<Shape*> shapes;

    shapes.push_back(new Rectangle(4, 5));
    shapes.push_back(new Circle(3));

    for (const Shape* shape : shapes) {
        std::cout << "Area: " << shape->calculateArea() << std::endl;
    }

    return 0;
}

之后我们创建了一个容器shapes,包含不同类型的图形对象,然后循环遍历该容器并为每一个shape对象调用 calculateArea 方法,尽管方法名称相同,但实际调用的方法是根据对象的类型动态确定的,这其实就是多态的概念。

代码编写

我们之前已经做了很久的铺垫,根据题目要求,Shape类应该具有方法获取面积和类型。

class Shape {
public:
      // 定义虚函数
      // const = 0 表示纯虚函数,该类不能被实例化,要求派生类必须实现这两个函数。
    virtual double CalculateArea() const = 0;
    virtual string GetType() const = 0;
};

之后,我们需要实现两个类RectangleCircle,它们都继承自类Shape

class Rectangle : public Shape {
public:
    // 初始化参数列表
    Rectangle(int width, int height) : width(width), height(height) {}
    // 计算面积
    double CalculateArea() const override {
        return static_cast<double>(width * height);
    }
    // 获取类型
    string GetType() const override {
        return "Rectangle";
    }
// 属性:width和height
private:
    int width;
    int height;
};
class Circle : public Shape {
public:
    // 初始化参数列表
    Circle(int radius) : radius(radius) {}
    // 计算面积
    double CalculateArea() const override {
        return 3.14 * radius * radius;
    }
    // 获取类型
    std::string GetType() const override {
        return "Circle";
    }
// 属性:radius
private:
    int radius;
};

之后,我们可以定义一个容器vector,用来放置建立的示例,并处理输入输出

int main() {
      // 定义一个容器,用来放置Shape类型
    vector<Shape*> shapes;
     
    return 0;
}

对输入的类型进行判断,如果是"end", 终止程序,如果是图形,则建立对应的实例

while (true) {
      // 获取输入的类型
    string type;
    cin >> type;
    // 如果输入的是 "end"
    if (type == "end") {
        break;
    }
    // 如果输入的是 rectangle
    if (type == "rectangle") {
        int width, height;
        cin >> width >> height; // 获取输入的宽和高
        shapes.push_back(new Rectangle(width, height)); // 新建对象,放到容器中
    } else if (type == "circle") {
        int radius;
        cin >> radius; // 获取输入的半径
        shapes.push_back(new Circle(radius)); // 新建对象,放到容器中
    }
}

最后, 遍历列表,并输出面积即可,题目要求输出小数点后两位,可以引入iomanip库文件中的内容,当使用 fixed 时,浮点数会以固定小数点格式输出,setprecision()函数用于设置输出浮点数的精度,即小数点后的位数, fixed << setprecision(2)表示输出小数点后两位。

// 输出结果,控制小数位数为两位
for (const Shape* shape : shapes) {
    cout << shape->GetType() << " area: " << fixed << setprecision(2) << shape->CalculateArea() << endl;
}

完整的代码如下:

#include <iostream>
#include <vector>
#include <string>
// 引入iomanip库文件,用于控制输出格式
#include <iomanip>

using namespace std;
// Shpe类
class Shape {
public:
    // 定义计算面积和获取类型的函数为纯虚函数
    virtual double CalculateArea() const = 0;
    virtual string GetType() const = 0;
};

class Rectangle : public Shape {
public:
       // 初始化参数列表
    Rectangle(int width, int height) : width(width), height(height) {}
    // 计算长方形面积,将整数转为浮点数
    double CalculateArea() const override {
        return static_cast<double>(width * height);
    }
    // 获取图形的形状
    string GetType() const override {
        return "Rectangle";
    }
// 属性:宽度和高度
private:
    int width;
    int height;
};

class Circle : public Shape {
public:
    // 初始化参数列表
    Circle(int radius) : radius(radius) {}
    // 计算圆的面积
    double CalculateArea() const override {
        return 3.14 * radius * radius;
    }
    // 获取图形形状
    string GetType() const override {
        return "Circle";
    }
// 属性:半径
private:
    int radius;
};

int main() {
    // 定义一个容器,容纳 shape对象
    vector<Shape*> shapes;

    while (true) {
        string type;
        // 获取输入的 type类型
        cin >> type;

        if (type == "end") {
            break;
        }

        if (type == "rectangle") {
            // 获取输入的宽度和高度
            int width, height;
            cin >> width >> height;
            // 新建Rectangle对象
            shapes.push_back(new Rectangle(width, height));
        } else if (type == "circle") {
            int radius;
            // 获取输入的半径
            cin >> radius;
            // 新建 Radius 对象
            shapes.push_back(new Circle(radius));
        }
    }

    // 输出结果,控制小数位数为两位
    for (const Shape* shape : shapes) {
        // shape对象调用同一个方法,有不同的处理逻辑
        cout << shape->GetType() << " area: " << fixed << setprecision(2) << shape->CalculateArea() << endl;
    }

    return 0;
}

总结

本节课中,我们学习到了类的使用,并对面向对象的三大特性,封装、继承、多态有了一些理解。总结来说,封装确保了对象中的数据安全,继承使得代码更加简洁,而且保证了对象的可扩展性,而多态则保证了程序的灵活性,不同的对象调用同一个方法有不同的响应。

C++小课到这里就结束了,回顾一下,我们还是接触了很多内容的。

语言知识

  • 变量和常量

  • 数据类型(基本数据类型和数据类型转换)

  • 函数的组成部分

  • 输入输出流

  • C++标准库

  • 循环结构:for循环、while循环、do while循环

  • 条件结构:if 、else

  • 运算符:关系运算符、逻辑运算符、算术运算符、赋值运算符

  • 自增和自减、累加操作

  • 取模运算

  • breakcontinue

  • 循环嵌套

  • 数组、容器、字符串的概念、特点、声明、访问和操作方法

  • printf函数、getline()函数、getchar()函数

  • flag编程思想

  • 字符大小的比较

  • 函数的定义和使用、形参和实参

  • 引用&

  • 结构体

  • 指针

  • 构造函数

  • new运算符

  • 箭头语法

  • set的概念、特点和基本操作

  • map的概念、特点和基本操作

  • 迭代器iterator

  • const限定符

  • pair类型

  • 范围for循环

  • stack的概念、特点和基本操作

  • queue的概念、特点和基本操作

数据结构

此外,我们还接触了一些基本的数据结构,它们会在后面的刷题之旅中经常使用。

  • 数组(一维数组、二维数组)

  • 字符串

  • 链表

  • 哈希表

  • 队列

最后

通过以上知识的学习,大家在刷题的时候,基本解决语言方面的问题了,此时就需要更专注于算法知识的学习,接下来大家去刷代码随想录,那里有新的挑战在等着你!

最后也希望大家可以写一篇总结博客,来记录这一刻,加油💪

如果认可本课程,想成为本课的分销代理(就是通过你的链接购买本课的话,你会得到佣金),可以发卡哥邮箱:programmercarl@163.com,说明自己的背景(学校,年级,校园活动等等),为什么想成为本课的销售代理,以及自己的微信联系方式。审核通过后,会给你开通。

不会做游戏!