拷贝控制
每个类都定义了一个新类型和在此类型对象上可执行的操作。类可以定义构造函数,用来控制在创建此类型对象时做什么。
当定义一个类时,我们显式或隐式地指定在此类型的对象拷贝、移动、赋值和销毁时做什么。一个对象时通过定义5种特殊的成员函数来控制这些操作:拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符、析构函数。
如果一个类没有定义所有这些拷贝控制成员。编译器会自动为它定义缺失的操作,因此很多类会自动位它定义缺失的操作,但是对一些类来说,以来这些默认的定义会导致灾难。
1.拷贝、赋值与销毁
1.1拷贝构造函数
1.如果一个构造函数的第一个参数是自身类类型,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。
class Foo{
public:
Foo(); //默认构造函数
Foo(const Foo&); //拷贝构造函数
.....
}
拷贝构造函数在几种情况下都会被隐式地使用,因此不能是explict的(explicit不能进行隐式转换,且只能接受一个参数)。
2.合成拷贝构造函数
如果我们没有为一个类定义拷贝构造函数,编译器会为我们定义一个,就算我们定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数。但若我们定义了其他构造函数,系统便不会为我们提供默认构造函数,必须自己提供默认构造函数。
合成的拷贝构造函数会将拷贝对象的成员逐个拷贝到正在创建的对象中。编译器从给定对象中以此将每个非static成员拷贝到正在创建的对象中。对于类类型成员,会使用其拷贝构造函数来拷贝;对于内置类型成员会直接拷贝。
//Sales_data类的合成拷贝构造函数等价于“
class Sales_data{
public:
//其他成员和构造函数的定义
.....
//与合成的拷贝构造函数等价的拷贝构造函数的声明
Sales_data(const Sales_data&);
private:
std::string bookNo; //ISBN编号
int units_solod=0; //销量
double reven=0.0; //总销售收入
};
//与Sales_data的合成的拷贝构造函数等价,初始化列表形式。
Sales_data::Sales_data(const Sales_data&orig):
bookNo(orig.bookNo),
units_sold(orig.units_sold),
revenue(orig.revenue)
{}
3.拷贝初始化
拷贝初始化和直接初始化的差异例子:
string dots(10,'.'); //直接初始化
string s(dots); //直接初始化
string s2=dots; //拷贝初始化
string null_book="9-99-999"; //拷贝初始化
string nines=string(100,'9'); //拷贝初始化
使用直接初始化时,是使用普通的函数匹配来选择与提供的参数最匹配的构造函数;而使用拷贝初始化时,要求编译器将右侧运算对象拷贝到正在创建的对象中,有需要的话还要进行类型转换。
拷贝初始化发生的情况:
-
将一个对象作为实参传递给一个非引用类型的形参, 如:Sales_data(Sales_data rhs); -
用==定义变量时发生,如string s2=dots; -
从一个返回类型为费引用类型的函数返回一个对象。 -
用花括号列表初始化一个数组中的元素或一个聚合类中的成员。 4.参数和返回值 拷贝构造函数自己的参数必须是引用类型。原因是:为了调用拷贝构造函数,我们必须拷贝它的实参,但为了拷贝实参,我们又需要调用拷贝构造函数,如此无线循环。
5.拷贝初始化的限制
如果我们使用的初始化值要求通过一个explicit的构造函数来进行类型转换,那么使用拷贝初始化还是直接初始化就不是无关紧要的了:
vector<int>v1(10); //正确:直接初始化
vector<int>v2=10; //错误:接受大小参数的构造函数是explicit的
//vector接受单一大小参数的构造函数是explicit的,因此我们不能隐式使用一个explicit的构造函数。
void f(vector<int>) //f的参数进行拷贝初始化
f(10); //错误,不能用一个explicit的构造函数拷贝一个参
f(vector<int>(10)); //正确:从一个int直接构造一个临时vector
1.2拷贝赋值运算符
与拷贝构造函数一样,类可以控制其对象如何赋值且如果类未定义自己的拷贝赋值运算符,编译器会为其合成一个。
1.重载拷贝赋值运算符
重载运算符的本质上是函数,其名字由operator关键字后接要定义的运算符组成,如:operator>为>重载运算符。赋值运算符就是一个名为operator=的函数,也有自己的返回类型和一个参数列表。如果一个运算符是成员函数,其左侧运算对象绑定到隐式的this参数。对于一个二元运算符,其右侧运算对象作为显示参数传递。
拷贝赋值运算符接受一个与其所在类相同类型的参数,且应该返回一个指向其左侧运算对象的引用(为了可以实现连等运算)。
class Foo{
public:
Foo&operator==(const Foo); //赋值运算符
//...
}
2.合成拷贝运算符
如果一个类未定义自己的拷贝赋值运算符,编译器会为他生成一个合成拷贝运算符。类似拷贝构造函数,它会将右侧运算对象的每个非static成员赋予左侧运算对象的对应成员,这一工作是通过成员类型的拷贝赋值运算符来完成的。
//等价于合成拷贝赋值运算符
Sales_data&Sales_data::operator=(const Sales_data&rhs){
bookNo=rhs.bookNo; //调用string::operator
units_sold=rhs.units_sold; //使用内置的int赋值
revenue=rhs.revenue; //使用内置的double赋值
return *this; //返回一个此对象的引用
}
1.3析构函数
析构函数执行与构造函数相反的操作:构造函数初始化对象的非static数据成员;析构函数释放对象使用的成员,并销毁对象的非static数据成员。
1.析构函数完成什么工作
析构函数有一个函数体和一个析构部分,在一个析构函数中,首先执行函数体,然后销毁成员,成员按初始化逆序销毁。析构函数是在对象的最后一次使用之后调用的,释放对象在生存期分配的所有资源。
析构函数的析构部分是隐式的,成员销毁时发生什么完全依赖于成员的类型,销毁类类型的成员需要执行成员自己的析构函数,内置类型没有析构函数,因此销毁内置类型成员什么也不需要做。隐式销毁一个内置指针类型的成员不会delete它所指向的对象。
2.什么时候会调用析构函数
无论何时一个对象被销毁,就会自动调用其析构函数:
变量在离开作用域时被销毁。
当一个对象被销毁时,其成员被销毁。
容器被销毁时,其元素被销毁。
对于动态分配的对象,当对指向它的指针应用delete运算符时被销毁。
由于析构函数自动,我们的程序可以按需要分配资源,而无需担心何时释放这些资源。
例如:
{//新作用域
//p和p2指向动态分配的对象
Sales_data*p=new Sales_data; //p是一个内置指针
auto p2=make_shared<Sales_data>(); //p2是一个shared_ptr
Sales_data item(*p); //拷贝构造函数构造item
vector<Sales_data>vec; //局部对象
vec.push_back(*p2); //拷贝p2指向的对象
delete p; //对p指向的对象执行析构函数
}
//退出局部作用域;对item,p2和vec调用析构函数。p是内置类型,没有析构函数
//销毁p2会递减其引用计数;如果其引用计数变为0,对象被释放
//销毁vec会销毁它的元素
我们的代码唯一需要直接管理的内存就是我们直接分配的Sales_data对象,只需要直接delete释放绑定到p的动态分配对象。
其他Sales_data对象会在离开作用域时被自动销毁,程序块结束时,vec、p2和item都离开了作用域,这些对象上会分别vector、shared_ptr和Sales_data的析构函数。vector的析构函数会销毁我们添加到vec的元素,shared_ptr的析构函数会递减p2指向对象的引用计数,Sales_data的析构函数会隐式的销毁bookNo成员。
当指向一个对象的引用或指针离开作用域时,析构函数不会执行。
3.合成析构函数
当一个类未定义自己的析构函数时,编译器会为它定义一个合成析构函数。类似拷贝构造函数和拷贝赋值运算符。合成析构函数的函数体为空。
如,下面代码片段等价于Sales_data的合成析构函数:
class Sales_data{
public:
//成员被自动销毁,除此之外不需要做其他事情
~Sales_data(){}
//其他成员的定义,如前
}
在空的析构函数执行完毕之后,成员会被自动销毁,string的析构函数会被调用,释放bookNo成员所用的内存。
析构函数体本身并不直接销毁成员。成员是在析构函数体之后的隐含的析构阶段中被销毁的,在整个对象被销毁的过程中,析构函数体是作为成员销毁步骤之外的另一部分而进行的。
1.4三/五法则
有三个基本操作可以控制类的拷贝操作:拷贝构造函数、拷贝赋值运算符和析构函数。
c++语言并不要求我们定义所有这些操作:可以指定一其中一个或两个,但是,这些操作通常被看成一个整体。很少见到只定义其中一个操作,而不定义所有操作的情况。
三五法则:
- 需要析构函数的类也需要拷贝构造函数和拷贝赋值函数。
- 需要拷贝操作的类也需要赋值操作,反之亦然。
- 析构函数是不能删除的。
- 如果一个类有删除的或不可访问的析构函数,那么其默认和拷贝构造函数会被定义为删除的。
- 如果一个类有const引用成员,则不能使用合成的拷贝赋值操作。
1.需要析构函数的类也需要拷贝和赋值操作
但我们决定一个类是否需要定义自己本版的拷贝控制成员时,一个基本的原则是首先确定这个类是否需要一个析构函数。通常,对析构函数的需求要比对拷贝构造函数或赋值运算符的需求更为明显。如果这个类需要一个析构函数,可以肯定它也需要一个拷贝构造函数和一个拷贝赋值运算符。
例如:
//HasPtr类。
class HasPtr{
public:
HasPtr(const std::string &s=std::string())://默认构造
ps(new std::string(s),i(0)){}
//错误:HasPtr需要一个拷贝构造函数和一个拷贝赋值运算符
~HasPtr(){
delete ps;
}
private:
std::string*ps;
int i;
}
只定义了析构函数而不定义拷贝构造函数和拷贝赋值运算符会导致浅拷贝问题。
如:
HasPtr f(HasPtr hp){//HasPtr是传值函数没所以将被拷贝
HasPtr ret=hp; //拷贝给定的HasPtr
//处理ret
return ret; //ret和hp被销毁
}
当f返回时,hp和ret离开作用域被销毁,在两个对象上都会调用HasPtr的析构函数。此析构函数会delete ret和hp中的指针成员,但这两个对象包含相同的指针值,==会导致此指针被delete两次,将发生什么是未定义的。==此外,f的调用者还会使用传递给f的对象:
HasPtr p("some values");
f(p); //当f结束时,p.ps指向的内存被释放
HasPtr q(p); //现在p和q都指向无效内存
2.需要拷贝操作的类也需要赋值操作,反之亦然
虽然很多类需要定义所有(或者不需要定义所有)拷贝控制成员,但某些类所要完成的工作,只需要拷贝和赋值操作,不需要析构函数。
例如,一个类为每个对象分配一个独一的序号。
- 这个类需要一个拷贝构造函数为每个新创建的对象生成一个新的序号。
- 这个拷贝构造函数从给定对象拷贝所有其他数据成员。
- 这个类需要自定义拷贝赋值运算符类避免将序号赋予目的对象。
- 但是这个类不需要自定义析构函数。
这就引出第二条基本原则:如果一个类需要一个拷贝构造函数,它肯定也需要一个拷贝赋值运算符,反之亦然。但是,不必然意味着需要析构函数。
1.5使用=default
可以通过将拷贝控制成员定义为=default来显示的要求编译器生成合成版本。
如:
class Sales_data{
public:
//拷贝控制成员;使用default
//类内使用=default合成的函数隐式声明为内联的
Sales_data()=default;
Sales_data(const Sales_data&)=default;
Sales_data&operator=(const Sales_data&);
~Sales_data()=default;
//其他成员的定义如前
}
//类外使用=default不是内联的
Sales_data&Sales_data::operator=(const Sales_data&)=default;
在类内使用default修饰成员的声明时,合成的函数将隐式地声明名为内联的。如果我们不希望合成的成员时内敛函数,应该支队成员的类外定义使用=default。
只能对具有合成版本的成员函数使用=default。(即默认构造函数、拷贝构造函数、拷贝赋值运算符和析构函数)
1.6阻止拷贝
大多数类应该定义默认构造函数、拷贝构造函数和拷贝赋值运算符,无论是隐式地还是显式的。
虽然大多数类应该定义拷贝构造函数和拷贝赋值运算符,但对于某些类来说,这些操作没有意义。在此情况下,定义类时必须采用某种机制阻止拷贝或赋值。
1.定义删除的函数
我们可以将**拷贝函数和拷贝赋值运算符定义为删除的函数来阻止拷贝。**删除的函数是这样一种函数:我们虽然声明了它们,但不能以任何方式使用它们。
在函数的参数列表后面加=delete来指出我们希望将它定义为删除的:
struct NoCopy{
Nocopy()=default; //显式地使用合成的默认构造函数
NoCopy(const NoCopy&)=delete; //阻止拷贝
NoCopy&operator(const NoCopy&)=delete; //阻止赋值
~NoCopy()=default; //显式地使用合成的析构函数
//其他成员
};
与=default的两个不同之处:
- =default必须出现在函数第一次声明的时候。
- 我们可以对任何函数指定=delete,而只能对可以合成的函数使用=default。
2.析构函数不能是删除的成员
我们不能删除析构函数,如果析构函数被删除,就无法销毁成员,更不能销毁此类型的对象了。
3.合成的拷贝控制成员可能是删除的
如果一个类有数据成员不能默认构造、拷贝、赋值或销毁,其对应的成员函数将被定义为删除的:
-
如果类的某个成员的析构函数是删除的或不可访问的(如是private的),则类的合成析构函数将被定义为删除的。 -
如果类的某个成员的拷贝构造函数是删除的或不可访问的,则类的合成构造函数被定义为删除的。如果类的某个成员的析构函数是删除的或不可访问的,则类合成的拷贝构造函数也被定义为删除的。 -
如果类的某个成员的拷贝赋值运算符是删除的或不可访问的,或是类有一个const的或引用成员,则类的合成拷贝赋值运算符被定义为删除的。 -
如果类的某个成员的拷贝赋值运算符是删除的或不可访问的,或是类有一个const或引用成员,则类的合成拷贝赋值运算符被定义为删除的。 一个成员有删除的或不可访问的析构函数会导致合成的默认和拷贝构造函数被定义为删除的,原因是,如果没有这条规则,我们可能创建出无法销毁的对象。 对于具有引用成员或无法默认构造的const成员的类,编译器不会为其合成默认构造函数,也不能使用合成的拷贝赋值运算符,原因是,将一个新值赋给一个const对象是不可能的。 将一个新值赋给一个引用成员,改变的是引用指向的对象的值而不是引用本身。如果为这样的类赋值运算符,则赋值后,左侧运算对象仍然指向与赋值前一样的对象,而不会与右侧运算对象指向相同的对象,这不是我们期望的。因此对有引用成员的类,合成拷贝赋值运算符被定义为删除的。
4.private拷贝控制
希望阻止拷贝的类应该使用=delete来定义它们自己的拷贝构造函数和拷贝赋值运算符,而不应该将它们声明为private的。
2.拷贝控制和资源管理
通常,管理类外资源的类必须定义拷贝控制成员。这种类需要通过析构函数来释放对象所分配的资源。
为了定义这些成员,首先得确定此类型对象的拷贝语义,一般有两种选择:
- 定义拷贝操作,使类的行为像一个值,如string类。当我们拷贝一个像值的对象时,副本和原对象是独立的。改变副本不会对原对象有任何影响,反之亦然。
- 定义拷贝操作,使类的行为像一个指针,如shared_ptr类。行为像指针的类则共享状态。当我们拷贝一个这种类的对象时,副本和原对象使用相同的底层数据。改变副本也会改变对象,反之亦然。
2.1行为像值的类
为了提供类值的行为,对于类管理的资源,每个对象都要有自己的一份拷贝。这就意味着对于ps指向的string,每个HasPtr对象都必须有自己的拷贝。为了实现类值行为。HasPtr需要:
- 定义一个拷贝构造函数,完成string的拷贝,而不是拷贝指针。
- 定义一个析构函数来释放string。
- 定义一个拷贝赋值运算符来释放当前对象的string,并从右侧运算对象拷贝string。
类值版本的HasPtr:
类值拷贝赋值运算符
赋值运算符结合了析构函数和构造函数的操作。类似构造函数,赋值操作会销毁左侧运算对象的资源;类似拷贝构造函数,赋值操作会从右侧运算对象拷贝数据。
非常重要的一点是:这些操作必须以正确的顺序执行,即使将一个对象赋予它自身,也正确,这就要求进行如下的步骤:
2.2定义行为像指针的类
对于行为类似指针的类,我们需要为其定义拷贝构造函数和拷贝赋值运算符,来拷贝指针成员本身而不是它指向的string。类仍然需要析构函数来释放接受string参数的构造函数分配的内存。但是析构函数不能单方面地释放关联地string,只有当最后一个指向string地HasPtr销毁时,它才可以释放string。
令一个类展现类似指针的行为地最好方法是使用shared_ptr来管理类中的资源。但是,我们有时希望直接管理内存。在这种情况下,使用引用计数就很有用了。
1.引用计数
引用计数的工作方式如下:
- 除了初始化对象外,每个构造函数(拷贝构造函数除外),还要创建一个引用计数,用来记录有多少对象正在与正在创建的对象共享状态。当我们创建一个对象时,只有一个对象共享状态,因此将计数器初始化为1.
- 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器。拷贝构造函数递增共享的计数器,指出给定对象的状态又被一个新用户所共享。
- 析构函数递减计数器,指出共享状态的用户少了一个。如果计数器变为0,则西沟函数释放状态。
- 拷贝赋值运算符递增右侧运算对象的计数器,递减左侧运算对象的计数器。如果左侧运算对象的计数器变为0,意味着它的共享状态没有用户了,拷贝赋值运算符就必须销毁状态。
计数器不能直接作为HasPtr对象的成员。下例说明:
HasPtr p1("Hiya!");
HasPtr p2(p1); //p1和p2指向相同的string
HasPtr p3(p1); //p1、p2和p3都指向相同的string
如果引用计数保存在每个对象中,当创建p3时如何正确更新它呢?可以递增p1的计数器并将其拷贝到p3中,但如何更新p2的计数器呢?
解决此问题的方法是将计数器保存到动态内存中。当创建一个对象时,我们也分配一个新的计数器。当拷贝或赋值对象时,我们拷贝指向计数器的指针。使用这种方法,副本和原对象都会指向相同的计数器。
class HasPtr{
public:
//构造函数分配新的string和新的计数器,将计数器置为1
HasPtr(const std::string&s=std::string()):
ps(new std::string(s),i(0),use(new std::size_t(1))){}
//拷贝构造函数拷贝所有三个数据成员,并递增计数器
HasPtr(const HasPtr&p):
ps(p.ps),i(p.i),use(p.use){++*use;}
HasPtr&operator=(const HasPtr&);
~HasPtr();
private:
std::string*ps;
int i;
std::size_t *use;
};
HasPtr&HasPtr::operator=(const HasPtr&rhs){
++*rhs.use;//递增右侧运算对象的引用计数
if(--*use==0){ //然后递减本对象的引用计数
delete ps; //如果没有其他用户
delete use; //释放本对象分配的成员
}
ps=rhs.ps; //将数据从rhs拷贝到本对象
i=rhs.i;
use=rhs.use;
return *this; //返回本对象
}
HasPtr::~HasPtr(){
if(--*use==0){ //如果引用计数变为0
delete ps; //释放string内存
delete use; //释放计数器内存
}
}
-
我们添加了换一个名为use的数据成员,它记录有多少对象共享相同的string。接受string参数的构造函数分配新的计数器,并将其初始化为1,指出当前有一个用户使用本对象的string成员。 -
**类指针的拷贝成员“篡改”引用计数。**当拷贝或赋值一个HasPtr对象时,副本和对象都指向相同的string。拷贝ps指针,而不是ps只想的string,当我们进行靠背时,还会递增改string关联的计数器。 -
拷贝构造函数拷贝给定HasPtr的所有三个数据成员。这个构造函数还会递增use成员,指出ps和p.ps指向的string又有了一个新用户。 -
析构函数不能无条件的delete ps,而应该递减引用计数,指出共享string的对象少了一个。如果计数器变为0,则西沟函数释放ps和use指向的内存。 -
拷贝赋值运算符与往常一样执行类似拷贝构造函数和析构函数的工作,即递增右侧运算符对象的引用计数,递减左侧运算符对象的引用计数。并且处理自赋值情况。
3.交换操作
定义拷贝控制成员,管理资源的类通常还定义一个名为swap的函数。对于与重排元素顺序的算法一起使用的类(如vector),定义swap非常重要。这类算法在需要交换两个元素时会调用swap。如果一个类定义了自己的swap,那么算法将使用自定义swap。否则,算法将使用标准库定义的swap。
//交换两个值类型的HasPtr对象的swap(值交换)
HasPtr temp=v1; //创建v1的值的一个临时副本
v1=v2; //将v2的值赋予v1
v2=temp; //将保存的v1的值赋予v2
//交换两个值类型的HasPtr对象的swap(指针交换)
string*temp=v1.ps; //为v1.ps中的指针创建一个副本
v1.ps=v2.ps; //将v2.ps中的指针赋予v1.ps
v2.ps=temp; //将保存的v1.ps中原来的指针赋予v2.ps
上面值交换的代码将原来v1中的string拷贝了两次,拷贝一个类值的HasPtr会分配一个新string并将其拷贝到HasPtr指向的位置。理论上这些内存分配都是不必要的,我们更希望swap交换指针,而不是分配string的副本。因此,可以用下面的代码。
1.编写自己的swap函数
class HasPtr{
friend void swap(HasPtr&,HasPtr&);
//其他成员定义。
}
inline
void swap(HasPtr& lhs,HasPtr& rhs){
using std::swap;
swap(lhs.ps,rhs.ps); //交换指针,而不是string数据
swap(lhs.i,rhs.i); //交换int成员
}
将swap声明为friend以便能访问HasPtr的(private的)数据成员。**由于swap的存在是为了优化代码,因此,我们将其声明为inline函数。**成员函数swap内调用的swap是标准库的swap。与拷贝控制成员不同,swap并不是必要的。但是对于分配了资源的类,定义swap可能是一种很重要的优化手段。
swap函数应该调用swap,而不是std::swap
在本例中,数据成员是内置类型的,而内置类型的没有特定版本的swap,对swap的调用会调用std::swap。
但是如果一个类的成员有自己类型特定的swap函数,调用std::swap就是错误的。例如,有一个Foo类,它有一个类型为HasPtr的成员h。如果我们未定义Foo版本的swap,那么就会使用标准库版本的swap。如下所示,标准库版本的swap对HasPtr管理的string进行了不必要的拷贝:
class swap{
//成员如上
};
class Foo{
public:
void swap(Foo&lhs,Foo&rhs);
//其他公有成员
private:
HasPtr h;
//其他私有成员
};
//错误Foo版本的swap:
void swap(Foo&lhs,Foo&rhs){
//错误:使用了标准库版本的swap,而不是HasPtr版本
std::swap(lhs.h,rhs.h);
//交换Foo的其他成员
}
//正确的Foo版本的swap
void swap(Foo&lhs,Foo&rhs){
using std::swap;
swap(lhs.h,rhs.h);//使用HasPtr版本的swap
//交换类型Foo的其他成员
}
每个swap的调用应该是未加限定的,每个调用应该是swap而不是std::swap。如果存在特定类型的swap版本,其匹配程度会优于std::swap版本。
2.在赋值运算符中使用swap
定义swap的类通常用swap来定义它们的赋值运算符。这些运算符使用了一种名为拷贝并交换的技术。这种技术将左侧对象与右侧运算对象的一个副本进行交换:
//rhs是按值传递的,意味着HasPtr的拷贝构造函数将从右侧运算对象中的string拷贝到rhs
HasPtr&HasPtr::operator=(HasPtr rhs){
//交换左侧运算对象和局部变量rhs的内容
swap(*this,rhs);//rhs现在指向本对象曾经使用的内存
return *this; //rhs被销毁,从而delete了rhs中的指针
}
rhs是右侧运算对象的一个副本。参数传递时拷贝HasPtr的操作会分配string的一个新副本。调用swap来交换rhs和this中的数据成员。这个调用将左侧运算对象中原来保存的指针存入rhs中,并将rhs中原来的指针存入this中。在调用swap之后,*this中的指针成员将指向新分配的string-右侧运算对象中string的一个副本。使用拷贝和交换的赋值运算自动就是异常安全的,且能正确处理自赋值。赋值运算符的过程是先拷贝运算符右侧对象到临时对象,然后释放左侧运算对象的值,再将数据从临时对象拷贝到左侧对象。跟swap是一样的原理。
|