c++学习记录:类的封装,继承和多态
从一段 C++ 图形代码理解面向对象:抽象类、继承、多态、clone 和对象切片
最近我学习 C++ 面向对象时,写了一段图形类代码。代码大概是这样:
#include<bits/stdc++.h>
using namespace std;
class graphic
{
public:
virtual double get_area() const=0;
virtual double get_perimeter() const=0;
virtual graphic *clone() const=0;
};
class circle:public graphic
{
public:
circle(double r)
{
this->r=r;
}
double get_area() const override
{
return r*r*pi;
}
double get_perimeter() const override
{
return r*2.0*pi;
}
graphic *clone() const override
{
return new circle(*this);
}
private:
double r;
const double pi{3.14};
};
class square:public graphic
{
public:
square(double r)
{
this->r=r;
}
double get_area() const override
{
return r*r;
}
double get_perimeter() const override
{
return r*4.0;
}
graphic *clone() const override
{
return new square(*this);
}
private:
double r;
};
class graphic_list
{
public:
void add(const graphic &g)
{
graphics.emplace_back(g.clone());
}
void print_all() const
{
for(auto *u:graphics)
{
cout<<"perimeter:"<<u->get_perimeter()<<endl;
cout<<"area:"<<u->get_area()<<endl;
cout<<endl;
}
}
private:
vector<graphic *> graphics;
};
int main()
{
graphic_list my_list;
my_list.add(circle{4});
my_list.add(square{5});
my_list.print_all();
return 0;
}
原版代码是这样的:
#include<bits/stdc++.h>
using namespace std;
class graphic
{
public:
virtual double get_area() const =0;
virtual double get_perimeter() const =0;
virtual graphic *clone() const =0;//why *
};
class circle : public graphic
{
public:
circle(double r)
{
this->r=r;
}
double get_area() const override
{
return r*r*pi;
}
double get_perimeter() const override
{
return r*2.0*pi;
}
graphic *clone() const override
{
return new circle(*this);
}
private:
double r;
const double pi{3.14};
};
class square : public graphic
{
public:
square(double r)
{
this->r=r;
}
double get_area() const override
{
return r*r;
}
double get_perimeter() const override
{
return r*4.0;
}
graphic *clone() const override
{
// return new graphic(*this); why output err?
return new square(*this);
}
private:
double r;
};
class graphic_list
{
public:
void add(const graphic &g)//为什么要加const和&
{
graphics.emplace_back(g.clone());
}
void print_all() const
{
for(auto *u:graphics)
{
cout<<"perimeter:"<<(*u).get_perimeter()<<endl;
cout<<"area:"<<(*u).get_area()<<endl;
cout<<endl;
/*
for(auto u:graphics)
{
cout<<"perimeter:"<<u.get_perimeter()<<endl;
cout<<"area:"<<u.get_area()<<endl;
cout<<endl;
}
why this error
*/
}
}
private:
vector<graphic*> graphics;//why *
};
int main()
{
graphic_list my_list;
my_list.add(circle(4));
my_list.add(square(2));
my_list.add(circle(4));
my_list.print_all();
return 0;
}
这段代码表面上是在算圆和正方形的面积、周长,但实际上已经包含了 C++ 面向对象里几个关键概念:
- 对象
- 类
- 抽象类
- 纯虚函数
- 继承
- 多态
- 父类指针指向子类对象
- 对象切片
- clone 多态复制
- 引用传参
- 指针容器
下面按我学习时遇到的问题逐个总结。
1. 什么是对象?
对象可以简单理解成:
一份具体的数据 + 能操作这份数据的函数。
比如:
circle c{4};
这里 circle 是类,c 才是对象。
c 这个对象内部可以理解成:
circle对象
{
r=4;
pi=3.14;
get_area();
get_perimeter();
}
严格来说,成员函数不是真的每个对象单独存一份,但从使用角度看,可以先理解成:对象有自己的数据,并且可以调用属于这个类的函数。
类是模板,对象是根据模板创建出来的具体实体。
比如:
circle a{4};
circle b{10};
a 和 b 都是 circle 对象,但它们的数据不同:
a.r=4
b.r=10
2. 成员变量为什么可以写在类的最后?
比如:
class circle:public graphic
{
public:
double get_area() const override
{
return r*r*pi;
}
private:
double r;
const double pi{3.14};
};
虽然 r 和 pi 写在后面,但前面的成员函数依然可以使用它们。
原因是:编译器会先看完整个类的定义。
类里的成员函数可以访问同一个类中的成员变量,不要求成员变量一定写在成员函数前面。
这和普通函数里的变量不一样。普通函数里变量通常需要先定义再使用。
3. this->r=r; 里面的 this-> 是什么?
构造函数里有:
circle(double r)
{
this->r=r;
}
这里左边和右边都叫 r,但它们不是同一个东西。
this->r:当前对象的成员变量r:构造函数传进来的参数
所以:
this->r=r;
意思是:
当前对象的r = 参数r;
如果写成:
r=r;
那就会变成参数自己给自己赋值,成员变量没有被正确赋值。
如果参数换个名字,就可以不写 this->:
circle(double x)
{
r=x;
}
更推荐的写法是初始化列表:
circle(double r):r(r)
{
}
这里:
:r(r)
前面的 r 是成员变量,后面的 r 是参数。
4. const double pi{3.14}; 为什么这样初始化?
const double pi{3.14};
这是类内成员默认初始化。
它表示:每个 circle 对象里的 pi 默认就是 3.14。
也可以写成:
const double pi=3.14;
花括号 {} 是 C++ 里比较统一的初始化方式,也能防止某些隐式类型转换问题。
比如:
int x=3.14;
这可以编译,但会把 3.14 截断成 3。
而:
int x{3.14};
一般会报错,因为它阻止了这种有风险的窄化转换。
另外,pi 是 const,所以它必须在初始化阶段确定值,不能等进入构造函数后再赋值。
5. 什么是抽象类?
父类 graphic 是这样写的:
class graphic
{
public:
virtual double get_area() const=0;
virtual double get_perimeter() const=0;
virtual graphic *clone() const=0;
};
这里有三个纯虚函数:
virtual double get_area() const=0;
virtual double get_perimeter() const=0;
virtual graphic *clone() const=0;
只要一个类里面有至少一个纯虚函数,这个类就是抽象类。
=0 不是返回 0,而是表示:
父类不实现这个函数,要求子类必须实现。
所以 graphic 的含义是:
所有图形都应该能算面积
所有图形都应该能算周长
所有图形都应该能复制自己
但 graphic 自己不知道具体怎么算
因此,graphic 不能创建对象:
graphic g;
这是错误的。
因为编译器不知道:
g.get_area()
g.get_perimeter()
g.clone()
到底应该执行什么。
6. 为什么 circle 和 square 可以创建对象?
circle 继承了 graphic:
class circle:public graphic
并且实现了父类要求的所有纯虚函数:
double get_area() const override
double get_perimeter() const override
graphic *clone() const override
所以 circle 不再是抽象类,可以创建对象:
circle c{4};
square 也是一样。
规则是:
父类有纯虚函数 -> 抽象类 -> 不能创建对象
子类实现所有纯虚函数 -> 具体类 -> 可以创建对象
如果 circle 少实现一个函数,比如没写 clone(),那么 circle 仍然是抽象类,也不能创建对象。
7. override 是什么?
比如:
double get_area() const override
{
return r*r*pi;
}
override 最好翻译成 重写。
它的作用是告诉编译器:
我这里是在重写父类的虚函数,请帮我检查
父类是:
virtual double get_area() const=0;
子类必须签名一致:
double get_area() const override
注意 const 不能少。
如果写成:
double get_area() override
就不是同一个函数,因为父类版本后面有 const,子类版本没有。
这时 override 会让编译器报错,帮我们提前发现问题。
所以可以记成:
virtual -> 父类说这个函数允许被重写
override -> 子类说我正在重写,请检查
8. 为什么 add 要写成 const graphic &g?
代码是:
void add(const graphic &g)
{
graphics.emplace_back(g.clone());
}
这里有两个重点:& 和 const。
为什么要加 &?
如果写成:
void add(graphic g)
这是错的。
因为按值传参需要创建一份新的 graphic 对象。
但 graphic 是抽象类,不能创建对象。
而且即使 graphic 不是抽象类,也会发生对象切片。
所以要写引用:
void add(const graphic &g)
这样不会复制对象,只是让 g 引用传进来的真实对象。
比如:
my_list.add(circle{4});
此时:
g 引用的是一个 circle 对象
不是创建了一个新的 graphic 对象。
为什么要加 const?
const graphic &g
表示:
我只是借用这个图形,不会修改它
这样更安全。
同时因为父类里的 clone() 也写了 const:
virtual graphic *clone() const=0;
所以 const graphic& 也能调用:
g.clone()
9. 什么是对象切片?
对象切片是理解这段代码的关键。
假设有:
class graphic
{
};
class circle:public graphic
{
private:
double r;
};
一个 circle 对象可以理解成:
circle对象 =
[
graphic父类部分
circle自己的r
]
如果按值赋给父类对象:
circle c{4};
graphic g=c;
这时会发生对象切片。
也就是:
circle{graphic部分 + r}
被切成:
graphic{graphic部分}
circle 自己的 r 丢了。
所以多态代码里一般不能这样写:
void add(graphic g)
也不能这样保存:
vector<graphic> graphics;
因为它们都会试图按值保存父类对象,要么因为抽象类不能创建而报错,要么发生对象切片。
正确做法是用引用或指针:
void add(const graphic &g)
vector<graphic *> graphics;
10. 为什么 vector<graphic *> graphics; 要存指针?
因为我们想在同一个容器里保存不同类型的图形:
circle
square
triangle
如果写:
vector<graphic> graphics;
不行。
原因有两个:
第一,graphic 是抽象类,不能创建对象。
第二,即使它不是抽象类,保存 circle、square 时也会对象切片。
所以要写:
vector<graphic *> graphics;
它保存的是对象地址,不是对象本体。
可以理解成:
graphics[0] ---> circle对象
graphics[1] ---> square对象
虽然容器里都是 graphic*,但它们真实指向的对象可以不同。
11. 父类指针能不能接住子类指针?
可以。
比如:
circle *p=new circle(4);
graphic *q=p;
这是合法的。
因为:
circle 是一种 graphic
所以:
circle* -> graphic*
可以自动转换。
你的代码里:
graphic *clone() const override
{
return new circle(*this);
}
new circle(*this) 的类型是:
circle*
但是函数返回类型是:
graphic*
因为 circle* 可以转成 graphic*,所以合法。
可以理解成:
circle *tmp=new circle(*this);
graphic *ret=tmp;
return ret;
反过来不一定可以:
graphic *p=new circle(4);
circle *q=p;
这不能直接写。
因为 graphic* 不一定真的指向 circle,也可能指向 square。
父类指针转子类指针需要额外判断,比如 dynamic_cast。
12. clone() 为什么返回 graphic*?
父类里写:
virtual graphic *clone() const=0;
它的意思是:
每个图形都要能复制自己,并返回一个父类指针
子类 circle 实现:
graphic *clone() const override
{
return new circle(*this);
}
子类 square 实现:
graphic *clone() const override
{
return new square(*this);
}
这就是多态复制。
如果当前对象是 circle,那就复制出一个新的 circle。
如果当前对象是 square,那就复制出一个新的 square。
外层统一用 graphic* 接住。
这样 graphic_list 不需要关心具体类型:
graphics.emplace_back(g.clone());
如果 g 实际是圆,就复制圆。
如果 g 实际是正方形,就复制正方形。
13. 为什么不能 return new graphic(*this);?
在 square::clone() 里不能写:
return new graphic(*this);
原因有两个。
第一,graphic 是抽象类,不能创建对象:
new graphic(...)
不合法。
第二,语义也错。
在 square::clone() 里面,应该复制一个新的 square,而不是复制一个 graphic。
正确写法是:
return new square(*this);
在 circle::clone() 里则是:
return new circle(*this);
总结:
circle::clone() -> new circle(*this)
square::clone() -> new square(*this)
14. new circle(*this) 是什么意思?
在 circle::clone() 里:
graphic *clone() const override
{
return new circle(*this);
}
这里的:
*this
表示当前对象本体。
如果当前对象是:
circle c{4};
那么在 c.clone() 里面,*this 就是 c 自己。
所以:
new circle(*this)
等价于:
new circle(c)
它会调用 circle 的拷贝构造函数,复制出一个新的 circle 对象。
这和 move 不一样。
clone是复制一份,原对象还保留move是资源转移,原对象进入一种合法但不应该继续依赖原内容的状态
clone() 后,原对象还是完整的。
15. my_list.add(circle{4}); 里的 circle{4} 真的创建对象了吗?
会。
my_list.add(circle{4});
这里 circle{4} 会创建一个临时 circle 对象。
它不是“完全不占内存”。
它是真实对象,只是生命周期很短,由编译器自动管理。
执行过程可以理解成:
创建临时 circle{4}
const graphic &g 绑定到这个 circle 临时对象
g.clone()
clone 里面 new circle(*this)
把新对象地址存进 vector
add 结束后,临时 circle{4} 销毁
所以这里其实有两个对象:
circle{4} 临时对象
new circle(*this) 复制出来的新对象
临时对象在 add 结束后销毁。
clone() 创建的新对象被保存进 graphics。
16. for(auto *u:graphics) 为什么不是解引用两次?
代码:
for(auto *u:graphics)
{
cout<<"perimeter:"<<(*u).get_perimeter()<<endl;
cout<<"area:"<<(*u).get_area()<<endl;
}
这里不是解引用两次。
auto *u
这里的 * 是声明指针变量,不是解引用。
因为 graphics 的类型是:
vector<graphic *>
所以每个元素都是:
graphic*
因此:
for(auto *u:graphics)
等价于:
for(graphic *u:graphics)
真正解引用的是:
(*u).get_area()
它等价于:
u->get_area()
所以更常见写法是:
for(auto *u:graphics)
{
cout<<"perimeter:"<<u->get_perimeter()<<endl;
cout<<"area:"<<u->get_area()<<endl;
cout<<endl;
}
17. 为什么注释里的循环会错?
错误写法:
for(auto u:graphics)
{
cout<<"perimeter:"<<u.get_perimeter()<<endl;
cout<<"area:"<<u.get_area()<<endl;
}
因为 graphics 是:
vector<graphic *>
所以 u 的类型是:
graphic*
u 是指针,不是对象。
指针不能用:
u.get_area()
应该用:
u->get_area()
或者:
(*u).get_area()
所以正确写法是:
for(auto u:graphics)
{
cout<<"perimeter:"<<u->get_perimeter()<<endl;
cout<<"area:"<<u->get_area()<<endl;
cout<<endl;
}
这里 auto u 和 auto *u 都可以。
区别是 auto *u 更明确表达 u 是一个指针。
18. 主函数为什么不能写 my_list.add(g:circle{r:4});?
错误写法:
my_list.add(g:circle{r:4});
这不能编译。
原因有两个。
第一,C++ 普通函数调用不支持这种参数名写法:
g:...
C++ 不是 Python,不能这样写命名参数。
第二,circle{r:4} 也不适合这里。
你的 circle 有构造函数:
circle(double r)
所以应该通过构造函数传参:
circle{4}
正确写法是:
my_list.add(circle{4});
或者:
circle c{4};
my_list.add(c);
完整主函数:
int main()
{
graphic_list my_list;
my_list.add(circle{4});
my_list.add(square{5});
my_list.print_all();
return 0;
}
19. 这段代码怎么体现面向对象思想?
这段代码体现了面向对象的四个核心思想。
封装
circle 把数据藏起来:
private:
double r;
const double pi{3.14};
外界不能直接乱改 r,只能通过函数使用:
get_area()
get_perimeter()
对象自己管理自己的数据。
抽象
graphic 只规定图形应该有什么能力:
get_area()
get_perimeter()
clone()
它不关心具体怎么算。
这就是抽象。
继承
class circle:public graphic
class square:public graphic
说明:
circle 是一种 graphic
square 是一种 graphic
子类继承父类接口,并提供自己的实现。
多态
graphic_list 里保存的是:
vector<graphic *> graphics;
表面上都是 graphic*,但真实对象可能是:
circle
square
调用时:
u->get_area()
如果 u 指向圆,就执行圆的面积公式。
如果 u 指向正方形,就执行正方形的面积公式。
这就是多态:
同一个接口,不同对象执行不同实现。
20. 这段代码的核心逻辑
这段代码最重要的逻辑是:
graphic 是抽象父类,定义统一接口
circle 和 square 是具体子类,实现接口
add 用 const graphic& 接收任意子类,避免对象切片
clone 用虚函数复制真实子类对象
vector<graphic*> 用父类指针统一保存不同子类对象
print_all 通过虚函数调用具体对象自己的实现
用一句话总结:
对象是带有数据和行为的具体实体;面向对象就是把问题拆成一批对象,让每个对象负责自己的数据和行为,再通过统一接口把它们组织起来。
在这段代码里,graphic_list 不需要知道圆和正方形具体怎么算面积。
它只需要知道:
每个 graphic 都可以 get_area()
每个 graphic 都可以 get_perimeter()
真正怎么算,由 circle 和 square 自己决定。
这就是面向对象思想的价值。

浙公网安备 33010602011771号