c++学习记录一:Fraction类
(本文来自《c++简明教程》)
Point:一个简单的类
在讨论Fraction类之前,先来看一个简单的类,以下是c++ class关键字的常规语法:
1 | class 类名{ |
除非需要写一个子类,否则上述语法不会变得更复杂。在声明中,可以保护数据声明、函数声明或者同时包含这两种声明。下面是一个数据声明的简单例子。
1 | class Point { |
成语默认情况下都是private的。换言之,它们不能被直接访问。所以,上述Point类的声明实际是没有用处的。要想使这个类变得有用,类至少应该包含一个public成员:
1 | class Point { |
现在,类具有了实际的用处。有了Point类的声明之后,就可以开始声明单独的Point对象,比如pt1:
1 | Point pt1; |
还可以向单独的数据成员赋值:
1 | pt1.x = 1; |
在本例中,类不包含任何函数成员,可以将Point类视为一个数据字段的组合。声明Point类型的每一个对象都有一个x成员和y成员。另外,由于这些成员都是整数,所以我们可以像使用任何整数变量那样使用他们:
1 | cout<< pt1.y + 4; //打印两个整数之和 |
在离开Point类这个简单版本之前,需要提醒注意一下语法。类的声明是以一个分号结尾的:
1 | class Point { |
private: 仅成员可用(保护数据)
在上一节中,我们开发的Point类允许直接访问它的数据成员,因为它们被声明为public。
假如你希望禁止对数据成员的直接访问,又该怎么做?为此,c++的做法是禁止对这些数据成员的直接访问,要求类的用户调用特地的函数来“间接”访问它们。
下面是Point类的修改版本,它禁止从类的外部访问x和y,但允许通过几个成员函数来间接访问(读/写)它们:
1 | class Point { |
上述类声明仍然相当简单。马上就能看出它声明了3个public成员函数,即set(),get_x()和get_y()。另外,它还声明了两个private数据成员。这样一来,在声明Point对象之后,你就只能通过调用某个public成员函数来操纵类的数据成员:
1 | Point point1; |
上述语句将会打印:
10, 20
如果企图直接操纵数据成员,编译器会报错:
1 | point1.x = 10; //报错 |
当然,3个函数成员不能凭空生成,必须在程序的某个地方定义他们。函数定义可以放在任何位置–甚至可以放到单独的模块中,或者在编译好之后,把它们添加到c++标准库中。不管在什么情况下,都必须为函数提供类型信息。与普通的函数相比较,成员函数的一个区别在于,它们的函数原型是在类的声明中提供的。也正是因为这个原因,在代码开始使用类之前,必须声明类。
定义一个函数时,Point::前缀规定了函数定义的作用域。这样一来,编译器就知道该定义要应用于Point类声明的函数。这一点非常重要,因为每个类都可以包含一个名为set的函数。除此之外,可能还定义了一个名为set的全局函数。
1 | void Point::set(int new_x,int new_y) |
除了要将Point::这个作用域前缀附加到函数名之前,返回类型(void或int)仍然出现在它们平时应该出现的位置–即函数定义的第一行的开头。
到此为止,我们可以总结出成员函数定义的语法:
type class_name::functionname (argument_list) {
statements
}
声明并定义好成员函数之后,就可以凭借它们来控制数据。例如,可以重写一下Point::set函数,将负的输入值装换成正值。
1 | void Point::set(int new_x,int new_y) |
虽然类外部的函数代码不能直接引用private数据成员x和y,但是类自己的成员函数定义可以直接引用类成员,而无需加以限定–不管那些成员是不是private的。例如,以下语句可以为类成员x设置一个新值:
1 | x = new_x; |
测试Point类
创建好Point类后,我们可以像使用其他任何标准型名称(比如int,double,float等)那样使用”Point”这一名称。我们不需要使用其他任何关键字对”Point”这一名称进行限定。
以下程序将执行对Point类的简单测试,它会设置并获取一些数据。
1 | //Point.cpp |
幕后玄机
这是一个非常简单的例子,首先必须声明Point类,这样才能在main中使用它。有了类的声明之后,main函数一开始就可以声明Point类型的两个不同对象:
1 | Point pt1, pt2; |
随后,可以使用set, get_x和get_y函数对pt1和pt2进行操纵。下面的语句使用Point的成员函数来操纵对象pt1:
1 | pt1.set(10,20); |
下面的语句则使用同样的成员函数来操纵对象pt2:
1 | pt2.set(-5,-25); |
练习
重写set函数,让它为x和y的值规定一个100的上限:如果输入的值大于100,就把它变成100。同时修改main函数进行测试。
为Point类写两个新的成员函数:set_x和set_y,实现对x和y的值进行单独设置。记住和set函数一样,要反转输入的负号。
Fraction类入门
为了理解面向对象编程,一个好办法是从定义一种新的数据类型的角度去想这个问题。类是对语言本身的一种扩展。Fraction(分数)类就是一个很好的例子。该类存储两个数字,分别代表分子和分明。
如果需要存储像1/3或者2/7这样的数字,而且希望保证数字的绝对精确,就适合使用Fraction类。你甚至可以使用类来存储货币值,比如$1.57。
创建Fraction类时,非常重要的一点就是限制对数据成员的访问。之所以要这样,原因是多方面的。至少,应该防止0成为分母。甚至对于一些合法的运算,也有必要对比值进行合理的简化,确保每个有理数都有一个唯一的表达式。例如一下比值:
3/3, 2/4, 6/2, -1/-1, 2/-1
它们应该简化成:
1/1, 1/2, 3/1, 1/1, -2/1
在后续几节里,我们将开发一系列函数,以便自动完成这些工作。对于最终的Fraction类的用户来说,最大的一个好处就是有关分数的所有处理细节都被严格地隐藏起来。如果这个类是正确写成的,那么从来没有看过源代码的程序员也能用这个类来创建任意数量的Fraction对象,而且像下面这样完美运行:
1 | Fraction a(1,6); // a = 1/6 |
Fraction类的完整版本需要花几章的篇幅来开发。首先来看看最简单的版本。
1 | class Fraction{ |
上述声明由三部分组成:
- private数据成员
- public函数成员
- private函数成员。它们是一些支持函数,本章以后会用到它们。就目前来说它们将简单的返回零值。
声明好这些函数之后,就可以使用类来执行一些简单操作,比如:
1 | Fraction fract; |
到目前为止,似乎还没有新鲜的东西,因为这个类看起来并不比前面讲过的Point类复杂多少。但我们才刚刚开了一个头。
和以前一样,类中声明的函数–不管是public还是private–都必须在程序的某个地方进行定义。在类的声明中,已经提供了一系列函数原型,所以除了在类中,这些函数不需要在其他任何地方重新声明。
1 | void Fraction::set(int n,int d) { |
内联函数
Fraction类中有3个函数所做的事情十分简单:设置或获取数据。即使在类的更复杂的版本中,这些函数都不会变得更加复杂。正因为如此,它们才成为进行”内联”(inlining)的“最佳后备军”。
函数内联之后,编译器会采取有别于普通函数调用的方式来对待一个内联函数调用。调用不会将控制转移到一个新的程序位置。相反,编译器会将函数调用替换成函数主体。
例如,假定将set函数内联。如下所示:
1 | void set() {num = n; den = d;} |
那么一旦在程序代码中遇到以下语句:
1 | fract.set(1,2); |
编译器就会在这个位置插入set函数的机器码指令。这相当于在程序中嵌入以下c++代码:
1 | {fract.num = 1; fract.den = 2;} |
另外,假如get_num函数内联,那么以下表达式:
1 | fract.num() |
就会被替换成用于fract.num的值的机器码。但在这种情况下,或许会产生疑问:为什么还要写一个get_num函数呢?不是反正都会被替换成一个fract.num引用吗?让num成为一个public成员不是更好吗?
答案是:只有定义一个get_num函数,才能控制对num数据成员的访问–即使函数是内联的。假如将num变成一个public数据成员,那么Fraction的任何用户都能直接读写num的值–这便违背了我们定义一个类的初衷。
为了使一个函数成为内联函数,你需要将它的函数定义放到类的声明中。记住,函数定义的末尾不能添加一个分号。
1 | class Fraction { |
函数内联之后有什么好处?效率是重要的好处!假如函数采取的行动只是几条机器指令(比如将数据从一个特定的内存位置移动到另一个位置),就可以将其写成内联函数,从而改善程序的执行效率。一个正常的函数调用会在运行时产生一定数量的开销(由几条机器指令构成)。对于一个函数来说,只要它采取的行动的开销小于上述开销,就值得内联。
但是,假如函数包含的并不仅仅是几个简单的语句,就不应内联。记住,只要函数被内联,编译器就会将整个函数主体嵌入函数调用位置。所以,假如一个内联函数经常被调用,程序占用的空间就会无谓地增大。除此之外,内联函数还存在一些额外的限制。例如,他们不能使用递归。
我们的3个支持函数(normalize,gcf和lcm)都显得比较长,所以我们不准备进行内联。
查找最大公因数
Fraction类中采取的所有行动(将在接下去的几章详细描述)都建立在数值理论的两个基本概念的基础上:“最大公因数”和“最小公倍数”。下面我们来看这样一个函数:
1 | int gcf(int a,int b) |
这个函数几乎可以原封不动地插入Fraction类中,作为它的一个支持函数来使用。
但是请注意:上述版本的gcf假定两个输入值都是正整数。让我们想一想这个假定可能会造成什么问题。
首先,假如其中一个参数值是0,会发生什么?
如果你用不同的值来试验,会发现a = 0不会出现任何问题。但是,如果b = 0,就会出现被0除的情况。这会造成一个运行时错误,它会使程序终止允许。
本章开始写normalize函数时(Fraction类的另一个支持函数),会假定a和b都在gcf函数调用之前正确进行了调整。normalize函数进而会将包含0值的任何一个分数调整成0/1。无论如何,必须满足的一个最基本的保证是:传给b的值一定不能为0。
面向对象编程的主要目的之一是控制对函数的访问,将gcf设为私有,可以确保只有Fraction类的支持函数才能调用gcf;因此,通过精心地编程,我们可以保证永远不会将0传给b
另一个要考虑的问题是,假如传递的是负值,又会发生什么?通过试验不同的值,你会发现使用负号不会改变结果的绝对值;例如,gcf(25,35)返回5,gcf(25,-35)也返回5,但gcf(-25,-35)返回的就是-5。所以,这里存在一个问题:结果的正负很难预料。
考虑到这个类的目的,假如将所有分母和公因数都表示成正数,那么更容易生成正确的结果。在gcf函数中,我们可以在遇到“停止情况”的时候总是返回一个整数,从而满足这一要求。
1 | int gcf(int a,int b) |
查找最小公倍数
定义好gcf函数之后,Fraction类剩余的部分就比较好些了。另一个非常有用的函数是获取最小公倍数(LCM)的函数。该函数将用到上一节介绍的gcf函数。
为了查找LCM,关键在于首先分解出最大公因数,确保这个公因数最后只乘一次。否则,假如你直接让a和b相乘,就相当与公因数被乘了两次。所以,首先必须从a和b中移除公因数。
换言之,对于输入的两个数a和b,你首先要分解出GCF。用GCF来除a,再用它来除b。最后只乘一次GCF。
公式是:n = gcd(a, b);
lcm(a, b) = n * (a / n) * (b / n);
第二行可以简化成:lcm(a, b) = (a / n) * b
这样就可以很容易地写出lcm函数:
1 | int lcm(int a, int b) { |
很容易就能对这个算法进行验证。以200和300的情况为例。两者的最大公因数是100。根据上述lcm公式,可以得出它们的最小公倍数是600:lcm = 200 / 100 * 300 = 2 * 300 = 600
你可以对其他任何数对进行测试,验证该函数的正确性。
Fraction的支持函数
现在,我们已经知道了怎样写gcf和lcm函数。接着,可以很容易地把它们的代码添加到Fraction类中。下面展示了该类的第一个能实际工作的版本。normalize函数的代码也已经添加,它能在每一次运算之后对分数进行简化。
1 | //Fract1.cpp |
###幕后玄机
首先,我们包含了stdlib.h文件,以支持Fraction::gcf函数定义中要用到的abs函数。#include <stdlib.h>
gcf和lcm函数的代码可以从前两节拷贝,只是函数头必须修改一下。作为类的成员函数,函数名之前必须附加Fraction::前缀。不过,只需在函数头中进行这样的处理:
1 | int Fraction::gcf(int a,int b) { |
函数递归调用自身时,则不必使用Fraction::前缀:
return gcf(b, a % b);
这是因为在类的成员函数内部,默认使用的就是那个类的作用域。
类似地,Fraction::lcm函数在调用gcf时,默认使用的也是那个类的作用域。换言之,Fraction::lcm默认使用的就是Fraction::gcf:
1 | int Fraction::lcm(int a, int b) { |
一般情况下,当c++编译器每次遇到一个变量名或者函数名时,都会按照以下顺序来查找与那个名称对应的声明:
- 在同一个函数中(比如局部变量)
- 在同一个类中(比如类的成员函数)
- 如果在函数或者类的作用域中没有找到对应的声明,编译器就查找一个全局声明。
normalize函数其实是本例惟一新增的代码。函数做的第一件事情是处理涉及到0的情况。分母为0属于合法情况,但在这种情况下,分数需要正规化为0/1。除此之外,分子为0的所有分数都需要正规化为0/1:
0/1, 0/2, 0/5, 0/-1, 0/25
以上分数全部要正规化为0/1。
Fraction类的主要设计目标之一就是确保在数学意义上相等的所有值都正规化为同一个分数(以后实现“测试相等性”操作符时,这就会使问题变得简单得多)。另外,注意你还必须解决负数可能带来的问题。以下两个表达式代表的是同一个值:
-2/3, 2/-3
类似还有
4/5, -4/-5
最简单的解决方案就是测试分母:如果它小于0,就同时对分子和分母进行取反操作。这样依赖,就可以兼顾前两个例子所展示的问题:
1 | if(den < 0) { |
normalize剩余的部分非常容易理解:查找最大公因数,然后同时用它来除分子和分母。如下所示:
1 | int n = gcf(num, den); |
以分数30/59为例。它们的最大公因数是10。在normalize函数执行了必要的除法运算之后,得出最终的正规形式是3/5。
normalize函数具有多方面的重要性。首先,正如早先提到的那样,相等的值应该采取完全一致的方式来表示。其次,以后为Fraction类定义算术匀速时,分子和父母可能积累起相当大的数字。为了避免运行时出现溢出错误,必须抓住任何一个机会对Fraction表达式进行简化。
####练习
重写normalize函数,在其中使用除后赋值操作符(/=)。记住,以下表达式:a /= b
等价于:a = a / b
###测试Fraction类
完成对一个类的声明后,接着可以声明它的一个或多个对象,用那些对象来测试这个类。以下代码允许用户输入一个分式的值,并在分式简化(正规化)之后,读取分子和分母的值。循环确保用户可以不限次数地执行测试。
Fract2.cpp
1 | #include <iostream> |
###幕后玄机
对于这个程序,最重要的一点就是累的声明必须放在最前面–在main中引用类或者类的成员函数之前。声明了类之后,其他所有函数(包括main函数)就可以采取任意顺序放置。
一个好习惯是将类声明(连同其他所有必要的声明和预编译指令)放到一个头文件(.h文件中)。可以自己试验一下这样处理。假定这个头文件的名称是Fraction.h,那么需要在使用了Fraction类的任何程序中添加以下#include命令:#include "Fraction.h"
但是,没有内联的函数定义必须放在程序内部的某个地方,或者单独编译并链接到你的软件项目中。
main的第三行创建了一个未初始化的Fraction对象:Fraction fract
main的其他语句则设置Fraction对象,并取回它的值。注意,对set函数的调用会进行赋值操作,但set函数会同时调用normalize函数,这就使分式得以正确简化(正规化):
1 | fract.set(a,b); |
####练习
写一个程序来使用Fraction类。程序通过调用set函数来设置一系列值:2/2, 4/8, -9/-9, 10/50, 100/25。让程序打印结果,并验证每个分式都得以正确简化。
在本节中,你或许会注意到引入了#include预编译指令的一种新的语法形式,记住,为了获取某个c++标准库的支持,首选的方法是使用尖括号:#include
Fraction算术(加法和乘法)
为了创建一个实用的Fraction类,我们的下一步是添加两个简单的数学函数,即add(加)和mult(乘)。这两个函数本身没有为类实现操作符。但是,将它们添加到类中之后,真正的操作符函数就能够很轻松地写出来。
分数加法其实是最麻烦的,但你现在也许还记得起学校里教过的方法。求两个分数的加法结果时:A/B + C/D
诀窍在于先找到最小公分母(LCD),即B和D的最小公倍数:LCD = LCM(B, D)
辛运的是,我们已经写好了一个好用的支持函数lcm来做这件事情。然后A/B必须转换成使用了这个LCD的一个分数:
$$\frac{A}{B} * \frac{LCD/B}{LCD/B}$$
这样就能得到分母是LCD的一个分数。对C/D也要进行类似的处理,如下所示:
$$\frac{C}{D} * \frac{LCD/D}{LCD/D}$$
执行上述转换之后,两个分数的分母都变成了“最小公分母”(LCD),所以它们能加到一起。结果分数是:
$$\frac{(A * LCD/B) +(C * LCD/D)}{LCD}$$
所以完整的算法是:
- 计算LCD,它等于LCM(B,D)
- 将Quotient1设为LCD/B
- 将Quotient2设为LCD/D
- 将新分数的分子设为A * Quotient1 + C * Quotient2
- 将新分数的分母设为LCD
相比之下,两个分数的乘法运算就要简单得多: - 将新分数的分子设为A * C
- 将新分数的分母设为B * D
掌握了这些算法之后,就可以动手写实际的代码,声明并实现两个新函数,同时对类的新版本进行测试。
Fract3.cpp###幕后玄机1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80#include <iostream>
#include <stdlib.h>
using namespace std;
class Fraction {
private:
int num, den; //num分子,den分母
public:
void set(int n,int d)
{
num = n;
den = d;
normalize();
}
int get_num() {return num;}
int get_den() {return den;}
Fraction add(Fraction other);
Fraction mult(Fraction other);
private:
void normalize() //将分数转换为标准写法
int gcf(int a,int b); //gcf代表最大公因数
int lcm(int a,int b); //lcm代表最小公倍数
};
int main()
{
Fraction fract1, fract2, fract3;
fract1.set(1, 2);
fract2.set(1, 3);
fract3 = fract1.add(fract2);
cout << "1/2 plus 1/3 = "
<< fract3.get_num() <<" / " << fract3.get_den()<<endl;
return 0;
}
void Fraction::normalize()
{
//处理0的情况
if( den == 0 || num == 0) {
num = 0;
den = 1;
}
//确保只有分子才能为负数
if(den < 0) {
num *= -1;
den *= -1;
}
//分解出gcf
int n = gcf(num, den);
num = num / n;
den = den / n;
}
//最大公因数
int Fraction::gcf(int a,int b)
{
if(a % b ==0)
return abs(b);
else
return gcf(b,a % b);
}
//最小公倍数
//
int Fraction::lcm(int a, int b) {
return (a / gcf(a, b)) * b;
}
Fraction Fraction::add(Fraction other) {
Fraction fract;
int lcd = lcm(den, other.den);
int quot1 = lcd / den;
int quot2 = lcd / other.den;
fract.set(num * quot1 + other.num * quot2, lcd);
return fract;
}
Fraction Fraction::mult(Fraction other) {
Fraction fract;
fract.set(num * other.num, den * other.den);
return fract;
}
add和mult函数严格遵循前面描述的算法。注意,两个函数都使用了一种新的类型签名。两个函数获取的都是Fraction类型的一个参数,返回的也是一个Fraction的值。来研究一下add函数的类型声明:Fraction Fraction::add (Fraction other);
在上述声明中: - 声明最开头的Fraction表面函数返回的是Fraction类型的一个对象。
- 前缀Fraction::表面add函数是在Fraction类中声明的一个成员函数。
- 圆括号中的Fraction表明参数other具有Fraction类型。
虽然这里恰好在3个地方都使用了Fraction,但这并非必须的。例如,你的一个函数可能要获取int类型的一个参数,并返回一个Fraction对象,而且该函数不是在Fraction类的内部声明的。在这种情况下,它的声明应该是:Fraction my_func(int n);
由于Fraction::add函数返回的是Fraction类型的一个对象,所以在函数定义中,首先必须要创建一个对象。Fraction fract;
然后,函数开始应用前面描述过的算法:最后,在设置好新的Fraction对象(fract)的所有值之后,函数返回该对象。1
2
3
4int lcd = lcm(den, other.den);
int quot1 = lcd / den;
int quot2 = lcd / other.den;
fract.set(num * quot1 + other.num * quot2, lcd);return fract;
mult函数的设计思路与此相似。
####练习 - 改写main函数,允许它计算任意两个分数相加的结果,并打印结果。
- 改写main函数,允许它计算任意两个分数相乘的结果,并打印结果。
- 为早先介绍的Point类写一个add函数。该函数能将两个x值加起来,获得新的x值;将两个y值加起来,获得新的y值。
- 为Fraction类写sub(减)和div(除)函数,并main函数中添加相应的代码来测试它们。注意,sub的算法和add类似。但也可以写一个更简单的函数,也就是是用-1来乘参数的分子,再调用一下add函数。
小结
下面总结了本章的要点:
类声明具有以下形式:
class 类名 {声明
};
在c++中,struct关键字在语法上等价于class关键字,但两者存在一个重要的区别:使用struct来声明的类成员默认是公共的;相反,使用class来声明的类成员默认是私有的。
由于class关键字声明的一个类型的成员默认是私有的,所以你至少要用public关键字来声明一个公共成员。public:之后列出的所有成员都是公共成员,知道遇到类声明的末尾,或者遇到下一个private的关键字。例如:
1
2
3
4
5
6
7
8
9
10
11
12class Fraction{
private:
int num, den;
public:
void set(int n, int d);
int get_num();
int get_den();
private:
void normalize()
int gcf(int a,int b);
int lcm(int a,int b);
};类声明和数据成员声明必须以一个分号结尾(即使已经有一个标志结束的大括号})。
声明了一个类之后,就可以把它当作一个类型名称来使用,这和使用int,float,double等标准类型名称时没有区别。例如,声明好Fraction类之后,就可以声明一系列Fraction对象:
Fraction a, b, c, my_fraction, fract1;
类的函数可以引用一个类的其他成员(不管是private成员还是public成员),无需对这个引用进行限定。
每个成员函数都必须在程序的某个地方进行定义。
要将一个成员函数定义放到类声明的外部,需要使用以下语法:
1
2
3type class_name::function_name(argument_list) {
statements
}如果将一个成员函数定义放到类声明的内部,这个函数就会被“内联”(inline)。换言之,它不会产生像普通函数那样的函数调用开销。相反,用于实现函数的机器指令会内嵌到函数调用的位置。
类的声明必须放在使用该类的位置之前。相反,函数定义可以放到程序的任何地方(甚至能放到一个单独的模块中)。
函数(不管是不是成员函数)可以将类作为参数类型或者返回类型使用。假如一个函数的返回类型是一个类,就意味着它必须返回一个对象。为此,你必须在函数定义中首先创建好一个对象(作为函数的一个局部变量)。最后,让函数返回该变量。
c++学习记录一:Fraction类