后续步骤

编程和 C++ 简介

在线教程介绍了更多高级概念,请阅读第 III 部分。本单元将重点介绍如何使用指针以及开始使用对象。

通过示例学习 2

本单元的重点是进一步练习分解、理解指针以及开始使用对象和类。请完成以下示例。在收到要求时自行编写程序或进行实验。我们需要再次强调,要成为一名优秀的程序员,关键在于练习、练习、练习!

示例 1:更多分解练习

考虑一个简单游戏的以下输出:

Welcome to Artillery.
You are in the middle of a war and being charged by thousands of enemies.
You have one cannon, which you can shoot at any angle.
You only have 10 cannonballs for this target..
Let's begin...

The enemy is 507 feet away!!!
What angle? 25<
You over shot by 445
What angle? 15
You over shot by 114
What angle? 10
You under shot by 82
What angle? 12
You under shot by 2
What angle? 12.01
You hit him!!!
It took you 4 shots.
You have killed 1 enemy.
I see another one, are you ready? (Y/N) n

You killed 1 of the enemy.

第一个观察结果是介绍文本,在每次程序执行时显示一次。我们需要使用随机数生成器来定义每轮敌人的距离。我们需要一种从玩家获取角度输入的机制,这显然是处于循环结构中的,因为它会不断重复,直到我们击中敌人。我们还需要一个函数来计算距离和角度。最后,我们必须记录击中敌人所需的射击次数,以及程序执行期间击中了多少敌。以下是可能的主程序大纲。

StartUp(); // This displays the introductory script.
killed = 0;
do {
  killed = Fire(); // Fire() contains the main loop of each round.
  cout << "I see another one, care to shoot again? (Y/N) " << endl;
  cin >> done;
} while (done != 'n');
cout << "You killed " << killed << " of the enemy." << endl;

Fire 程序会处理游戏过程。在该函数中,我们会调用随机数生成器来获取敌人距离,然后设置循环来获取玩家的输入,并计算玩家是否击中了敌人。循环中的守卫条件是我们对敌人的远近。

In case you are a little rusty on physics, here are the calculations:

Velocity = 200.0; // initial velocity of 200 ft/sec Gravity = 32.2; // gravity for distance calculation // in_angle is the angle the player has entered, converted to radians. time_in_air = (2.0 * Velocity * sin(in_angle)) / Gravity; distance = round((Velocity * cos(in_angle)) * time_in_air);

由于会调用 cos() 和 sin(),因此需要添加 Math.h。请尝试编写此程序,这是进行问题分解的绝佳做法,也是对基本 C++ 的良好回顾。请记住,在每个函数中只执行一项任务。这是我们迄今为止编写的最复杂的程序,因此您可能需要一点时间。点击此处查看我们的解决方案。

示例 2:使用指针进行练习

使用指针时,请注意以下四点:
  1. 指针是用于存储内存地址的变量。在程序执行时,所有变量都存储在内存中,每个变量都有自己的唯一地址或位置。 指针是一种特殊类型的变量,包含内存地址而不是数据值。正如在使用普通变量时数据会被修改一样,存储在指针中的地址的值会被修改为指针变量。示例如下:
    int *intptr; // Declare a pointer that holds the address
                 // of a memory location that can store an integer.
                 // Note the use of * to indicate this is a pointer variable.
    
    intptr = new int; // Allocate memory for the integer.
    *intptr = 5; // Store 5 in the memory address stored in intptr.
          
  2. 我们通常说指针“指向”其存储的位置(“指针”)。因此,在上面的示例中,intptr 指向指针 5。

    请注意,我们使用了“new”运算符为整数指针分配内存。在尝试访问该焦点之前,我们必须先执行此操作。

    int *ptr; // Declare integer pointer.
    ptr = new int; // Allocate some memory for the integer.
    *ptr = 5; // Dereference to initialize the pointee.
    *ptr = *ptr + 1; // We are dereferencing ptr in order
                     // to add one to the value stored
                     // at the ptr address.
          

    * 运算符用于在 C 语言中解引用。C/C++ 程序员在使用指针时最常犯的错误之一是忘记初始化指针。这有时可能会导致运行时崩溃,因为我们正在访问内存中包含未知数据的位置。如果我们尝试修改这些数据,可能会导致细微的内存损坏,从而难以跟踪。

  3. 两个指针之间的指针分配会使它们指向同一个指针。因此赋值 y = x; 使 y 指向与 x 相同的指向点。指针分配不会接触指针。而只是更改一个指针,使其与另一个指针位于相同的位置。指针分配后,两个指针会“共享”指针。 
  4. void main() {
     int* x; // Allocate the pointers x and y
     int* y; // (but not the pointees).
    
     x = new int; // Allocate an int pointee and set x to point to it.
    
     *x = 42; // Dereference x and store 42 in its pointee
    
     *y = 13; // CRASH -- y does not have a pointee yet
    
     y = x; // Pointer assignment sets y to point to x's pointee
    
     *y = 13; // Dereference y to store 13 in its (shared) pointee
    }
      

以下是此代码的跟踪记录:

1. 分配 x 和 y 两个指针。分配指针不会分配任何指针。
2. 分配指针,并将 x 设置为指向它。
3. 解引用 x,以便在其指针对象中存储 42。这是解引用操作的基本示例。从 x 开始,按照箭头向上访问其指针。
4. 尝试解除对 y 的引用,以便在其指针对象中存储 13。这会崩溃,因为 y 没有指针,从未为其分配指针。
5. 指定 y = x;,使 y 指向 x 的指针。现在,x 和 y 都指向同一个端点,即“共享”。
6. 尝试解除对 y 的引用,以便在其指针对象中存储 13。这次它成功了,因为上一项作业为您提供了一个定位点。

如您所见,图片对于理解指针的使用情况非常有帮助。下面是另一个示例。

int my_int = 46; // Declare a normal integer variable.
                 // Set it to equal 46.

// Declare a pointer and make it point to the variable my_int
// by using the address-of operator.
int *my_pointer = &my_int;

cout << my_int << endl; // Displays 46.

*my_pointer = 107; // Derefence and modify the variable.

cout << my_int << endl; // Displays 107.
cout << *my_pointer << endl; // Also 107.

请注意,在此示例中,我们从未使用“new”运算符分配内存。我们声明了一个普通整数变量,并通过指针操纵该变量。

在此示例中,我们将演示如何使用 delete 运算符来取消分配堆内存,以及如何为更复杂的结构分配内存。我们将在另一节课中介绍内存组织(堆和运行时堆栈)。现在,您只需将堆视为可供正在运行的程序使用的内存的可用存储器。

int *ptr1; // Declare a pointer to int.
ptr1 = new int; // Reserve storage and point to it.

float *ptr2 = new float; // Do it all in one statement.

delete ptr1; // Free the storage.
delete ptr2;

在最后一个示例中,我们展示了如何使用指针通过对函数的引用来传递值。这就是修改函数中变量值的方式。

// Passing parameters by reference.
#include <iostream>
using namespace std;

void Duplicate(int& a, int& b, int& c) {
  a *= 2;
  b *= 2;
  c *= 2;
}

int main() {
  int x = 1, y = 3, z = 7;
  Duplicate(x, y, z);
  // The following outputs: x=2, y=6, z=14.
  cout << "x="<< x << ", y="<< y << ", z="<< z;
  return 0;
}

如果我们在重复函数定义中去掉参数 (&),将“按值”传递变量,即复制变量的值。在函数中对变量所做的任何更改都会修改副本。而不会修改原始变量。

当通过引用传递变量时,我们传递的不是变量值的副本,而是将变量的地址传递给函数。我们对局部变量所做的任何修改实际上都会修改传入的原始变量。

如果您是 C 程序员,这是一种新玩法。在 C 代码中,我们也可以执行相同的操作,即将 Duplicate() 声明为 Duplicate(int *x)。在这种情况下,x 是指向某个 int 的指针,然后使用参数 &xx 的地址)调用 Duplicate(),并在 Duplicate() 中解引用 x(请参阅下文)。不过,C++ 提供了一种更简单的方式,可以通过引用将值传递给函数,尽管原来的“C”方法仍然奏效。

void Duplicate(int *a, int *b, int *c) {
  *a *= 2;
  *b *= 2;
  *c *= 2;
}

int main() {
  int x = 1, y = 3, z = 7;
  Duplicate(&x, &y, &z);
  // The following outputs: x=2, y=6, z=14.
  cout << "x=" << x << ", y=" << y << ", z=" << z;
  return 0;
}

请注意,对于 C++ 引用,我们不需要传递变量的地址,也不需要对调用的函数内的变量解除引用。

以下程序会输出什么?画一张记忆图案,找出答案。

void DoIt(int &foo, int goo);

int main() {
  int *foo, *goo;
  foo = new int;
  *foo = 1;
  goo = new int;
  *goo = 3;
  *foo = *goo + 3;
  foo = goo;
  *goo = 5;
  *foo = *goo + *foo;
  DoIt(*foo, *goo);
  cout << (*foo) << endl;
}

void DoIt(int &foo, int goo) {
  foo = goo + 3;
  goo = foo + 4;
  foo = goo + 3;
  goo = foo;
} 

运行程序,看看您是否获得了正确答案。

示例 3:通过引用传递值

编写一个名为 Accelerated() 的函数,它将车辆的速度和速度作为输入值。函数添加速度值,以加快车辆的加速速度。speed 参数应以引用方式传递, amount 应通过值传递。点击此处查看我们的解决方案。

示例 4:类和对象

请考虑以下类:

// time.cpp, Maggie Johnson
// Description: A simple time class.

#include <iostream>
using namespace std;

class Time {
 private:
  int hours_;
  int minutes_;
  int seconds_;
 public:
  void set(int h, int m, int s) {hours_ = h; minutes_ = m; seconds_ = s; return;}
  void increment();
  void display();
};

void Time::increment() {
  seconds_++;
  minutes_ += seconds_/60;
  hours_ += minutes_/60;
  seconds_ %= 60;
  minutes_ %= 60;
  hours_ %= 24;
  return;
}

void Time::display() {
  cout << (hours_ % 12 ? hours_ % 12:12) << ':'
       << (minutes_ < 10 ? "0" :"") << minutes_ << ':'
       << (seconds_ < 10 ? "0" :"") << seconds_
       << (hours_ < 12 ? " AM" : " PM") << endl;
}

int main() {
  Time timer;
  timer.set(23,59,58);
  for (int i = 0; i < 5; i++) {
    timer.increment();
    timer.display();
    cout << endl;
  }
}

请注意,类成员变量的末尾带有下划线。这样做是为了区分局部变量和类变量。

请为此类添加一个递减方法。点击此处查看我们的解决方案。

科学的奇迹:计算机科学

练习

与本课程的第一个单元一样,我们不提供练习和项目解决方案。

谨记出色的计划...

...在逻辑上分解为多个函数,其中任何一个函数都且仅执行一项任务。

...有一个主程序,读起来像是程序功能的大纲。

...具有描述性的函数、常量和变量名称。

... 使用常量来避免程序中出现任何“神奇的”数字。

...拥有友好的用户界面。

热身锻炼

  • 练习 1

    整数 36 有一个独特的属性:它是一个完全平方数,也是 1 到 8 之间的整数的总和。下一个这样的数字是 1225,即 352,以及 1 到 49 之间的整数总和。找到下一个完全平方数,以及系列 1...n 的总和。下一个数字可能大于 32767。您可以使用熟悉的库函数(或数学公式)来加快程序的运行速度。您也可以使用 for 循环编写此程序,以确定某个数字是完全平方数还是一系列数的总和。(注意:根据您的机器和程序,找到此数字可能需要很长时间。)

  • 练习 2

    你的大学书店需要你帮助来估算其明年的业务。经验表明,销售情况在很大程度上取决于一本图书是课程必备图书还是选修课程,以及课程之前是否使用过此书。一本必读的新教科书将销售给 90% 的有意入学者,但如果之前已在课程中使用,则只有 65% 会购买。同样,40% 的潜在入学者会购买新的可选教科书,但如果在课堂上学过该教科书,则只有 20% 会购买。(请注意,这里的“二手”并不是指二手书。)

  • 编写一个程序,用于接受一系列图书作为输入(直到用户进入哨兵)。对于每本图书,系统都会索取:图书代码、图书的单本复制费用、当前现有图书数量、预期课程注册人数,以及表明图书是否为必需/可选、过去是否新书/曾用过的数据。作为输出,在格式美观的屏幕中显示所有输入信息以及必须订购的图书数量(如果有,请注意,仅订购新图书),每个订单的总费用。

    然后,在完成所有输入后,显示所有图书订单的总费用,以及商店支付定价的 80% 时的预期利润。由于我们尚未讨论处理进入程序的大量数据的任何方法(敬请期待!),因此只需一次处理一本图书并显示该图书的输出屏幕即可。 然后,当用户输入完所有数据后,您的程序应输出总值和利润值。

    在开始编写代码之前,请花点时间考虑此程序的设计。 将它分解为一组函数,然后创建一个 main() 函数,让它读起来像问题解决方案的大纲。确保每个函数执行一项任务。

    以下是示例输出:

    Please enter the book code: 1221
     single copy price: 69.95
     number on hand: 30
     prospective enrollment: 150
     1 for reqd/0 for optional: 1
     1 for new/0 for used: 0
    ***************************************************
    Book: 1221
    Price: $69.95
    Inventory: 30
    Enrollment: 150
    
    This book is required and used.
    ***************************************************
    Need to order: 67
    Total Cost: $4686.65
    ***************************************************
    
    Enter 1 to do another book, 0 to stop. 0
    ***************************************************
    Total for all orders: $4686.65
    Profit: $937.33
    ***************************************************

数据库项目

在本项目中,我们将创建一个功能齐全的 C++ 程序来实现一个简单的数据库应用。

我们的程序可让我们管理包含作曲家及其相关信息的数据库。该计划包含以下功能:

  • 能够添加新的作曲家
  • 能够对作曲家进行排名(例如,表明我们有多喜欢或多不喜欢作曲家的音乐)
  • 能够查看数据库中的所有作曲家
  • 能够按排名查看所有作曲家

“构建软件设计的方法有两种:一种是使其变得简单,以至于明显没有缺陷;另一种方式是使其变得复杂,没有明显的缺陷。第一种方法要困难得多。”- C.A.R. Hoare

我们中的很多人都学会了使用“程序化”方法进行设计和编码。我们首先要问的核心问题是“该计划必须做什么?”。我们将解决问题的方法分解为任务,每个任务解决一个问题。这些任务映射到程序中从 main() 或其他函数依序调用的函数。这种分步式方法非常适合我们需要解决的一些问题。但通常情况下,我们的程序不仅仅是任务或事件的线性序列。

采用面向对象 (OO) 的方法时,我们从问题“我对现实世界的哪些对象进行建模?”入手。我们没有按上述方式将程序划分为多个任务,而是将其划分为多个物理对象的模型。这些实体对象具有由一组属性定义的状态,以及它们可以执行的一组行为或操作。这些操作可能会更改对象的状态,或者可能会调用其他对象的操作。基本前提是,对象本身“知道”如何执行操作。

在 OO 设计中,我们根据类和对象(属性和行为)定义实体对象。OO 程序中通常包含大量对象。不过,其中许多对象本质上是相同的。请考虑以下要点。

类是对象的一系列常规属性和行为,它们可能实际存在于现实世界中。在上图中,我们有一个 Apple 类。无论什么类型的苹果,都有颜色和口味属性。我们还定义了一种行为,通过该行为,Apple 会显示其属性。

在此图中,我们定义了两个属于 Apple 类的对象。每个对象都具有与类相同的属性和操作,但该对象定义了特定类型的苹果的属性。此外,“显示”操作还会显示该特定对象的属性,例如,“Green”和“Sour”。

OO 设计由一组类、与这些类关联的数据以及这些类可执行的操作集组成。我们还需要确定不同类的交互方式。调用其他类对象的操作的某个类的对象可执行此交互。例如,我们可以有一个 AppleOutputer 类,通过调用每个 Apple 对象的 Display() 方法来输出一组 Apple 对象的颜色和口味。

以下是我们在进行 OO 设计时执行的步骤:

  1. 识别类,并大致定义每个类的对象存储为数据的内容以及对象可以执行的操作。
  2. 定义每个类的数据元素
  3. 定义每个类的操作,以及如何使用其他相关类的操作实现一个类的某些操作。

对于大型系统,这些步骤会以不同的详细级别反复执行。

对于 Composer 数据库系统,我们需要一个 Composer 类,用于封装我们想要在单个 Composer 上存储的所有数据。此类的对象可以对自身进行升位或降位(更改其排名),并显示其属性。

我们还需要一个 Composer 对象集合。为此,我们定义一个 Database 类来管理各个记录。此类的对象可以添加或检索 Composer 对象,并通过调用 Composer 对象的显示操作来显示各个对象。

最后,我们需要某种界面来提供对数据库的交互式操作。这是一个占位符类,也就是说,我们其实还不确定界面会是什么样,但我们知道需要一个类。可能会基于图形或基于文本。目前,我们定义一个占位符,稍后可以填充它。

现在,我们已确定 Composer 数据库应用的类,下一步是为这些类定义属性和操作。在更复杂的应用中,我们会使用铅笔和纸张、UML/CRC 卡OOD 来绘制类层次结构以及对象之间的交互方式。

对于我们的混合渲染器数据库,我们定义了一个 Composer 类,其中包含我们要在每个合成器上存储的相关数据。它还包含用于操纵排名和显示数据的方法。

Database 类需要某种结构来保存 Composer 对象。我们需要能够向结构添加新的 Composer 对象,以及检索特定的 Composer 对象。我们还希望按进入顺序或排名来显示所有对象。

界面类实现菜单驱动型界面,其中包含可调用 Database 类中的操作的处理程序。

如果类易于理解,并且其属性和操作清晰明了(就像在混合渲染应用中一样,那么设计这些类就相对容易)。但是,如果您对类如何相互关联和交互存有疑问,最好先将其写出来,并在开始编码之前完成细节。

了解设计并对其进行评估(即将进行详细说明)后,我们便会为每个类定义接口。此时我们并不关注实现细节 - 只关注属性和操作是什么,类的状态和操作中的哪些部分可用于其他类。

在 C++ 中,我们通常通过为每个类定义一个头文件来执行此操作。Composer 类包含要在 Composer 上存储的所有数据的私有数据成员。 我们需要访问器(“get”方法)和更改器(“set”方法),以及类的主要操作。

// composer.h, Maggie Johnson
// Description: The class for a Composer record.
// The default ranking is 10 which is the lowest possible.
// Notice we use const in C++ instead of #define.
const int kDefaultRanking = 10;

class Composer {
 public:
  // Constructor
  Composer();
  // Here is the destructor which has the same name as the class
  // and is preceded by ~. It is called when an object is destroyed
  // either by deletion, or when the object is on the stack and
  // the method ends.
  ~Composer();

  // Accessors and Mutators
  void set_first_name(string in_first_name);
  string first_name();
  void set_last_name(string in_last_name);
  string last_name();
  void set_composer_yob(int in_composer_yob);
  int composer_yob();
  void set_composer_genre(string in_composer_genre);
  string composer_genre();
  void set_ranking(int in_ranking);
  int ranking();
  void set_fact(string in_fact);
  string fact();

  // Methods
  // This method increases a composer's rank by increment.
  void Promote(int increment);
  // This method decreases a composer's rank by decrement.
  void Demote(int decrement);
  // This method displays all the attributes of a composer.
  void Display();

 private:
  string first_name_;
  string last_name_;
  int composer_yob_; // year of birth
  string composer_genre_; // baroque, classical, romantic, etc.
  string fact_;
  int ranking_;
};

Database 类也很简单。

// database.h, Maggie Johnson
// Description: Class for a database of Composer records.
#include  <iostream>
#include "Composer.h"

// Our database holds 100 composers, and no more.
const int kMaxComposers = 100;

class Database {
 public:
  Database();
  ~Database();

  // Add a new composer using operations in the Composer class.
  // For convenience, we return a reference (pointer) to the new record.
  Composer& AddComposer(string in_first_name, string in_last_name,
                        string in_genre, int in_yob, string in_fact);
  // Search for a composer based on last name. Return a reference to the
  // found record.
  Composer& GetComposer(string in_last_name);
  // Display all composers in the database.
  void DisplayAll();
  // Sort database records by rank and then display all.
  void DisplayByRank();

 private:
  // Store the individual records in an array.
  Composer composers_[kMaxComposers];
  // Track the next slot in the array to place a new record.
  int next_slot_;
};

请注意我们如何将特定于编辑器的数据仔细封装在单独的类中。我们可以在 Database 类中放置一个结构体或类来表示 Composer 记录,并直接在该类中访问该记录。但这将是“在对象化下”的,也就是说,我们没有尽可能地使用对象进行建模。

当您开始处理 Composer 和 Database 类的实现时,您会发现,使用单独的 Composer 类会更简洁。特别是,对 Composer 对象进行单独的原子操作可极大地简化 Database 类中 Display() 方法的实现。

当然,也存在“过度对象化”的情况,我们会尝试将所有内容设为类,或者拥有超出所需的类。您需要不断练习才能找到正确的平衡点,您发现各个程序员会有不同的观点。

要确定您是过度对象化还是对象化不足,通常可以通过仔细绘制类图表来解决问题。如前所述,在开始编码之前务必要做好类设计,这可以帮助您分析方法。用于此用途的常用表示法是 UML(统一建模语言)。现在,我们已经有了为 Composer 和 Database 对象定义的类,接下来需要一个可让用户与数据库进行交互的接口。只需使用一个简单的菜单即可:

Composer Database
---------------------------------------------
1) Add a new composer
2) Retrieve a composer's data
3) Promote/demote a composer's rank
4) List all composers
5) List all composers by rank
0) Quit

我们可以将界面实现为类或过程程序。并非 C++ 程序中的所有内容都必须是类。实际上,如果处理是按顺序或面向任务的(如此菜单程序所示),那么按一定程序实现也是可以的。 请务必以保留为“占位符”的方式实现它,即,如果我们希望在某个时刻创建图形界面,就应该不必更改系统中的任何内容,而只需更改界面。

我们需要完成一个用于测试类的程序才能完成申请。 对于 Composer 类,我们需要一个 main() 程序,该程序会接受输入、填充 composer 对象,然后显示该对象以确保该类正常运行。 我们还希望调用 Composer 类的所有方法。

// test_composer.cpp, Maggie Johnson
//
// This program tests the Composer class.

#include <iostream>
#include "Composer.h"
using namespace std;

int main()
{
  cout << endl << "Testing the Composer class." << endl << endl;

  Composer composer;

  composer.set_first_name("Ludwig van");
  composer.set_last_name("Beethoven");
  composer.set_composer_yob(1770);
  composer.set_composer_genre("Romantic");
  composer.set_fact("Beethoven was completely deaf during the latter part of "
    "his life - he never heard a performance of his 9th symphony.");
  composer.Promote(2);
  composer.Demote(1);
  composer.Display();
}

对于 Database 类,我们需要一个类似的测试计划。

// test_database.cpp, Maggie Johnson
//
// Description: Test driver for a database of Composer records.
#include <iostream>
#include "Database.h"
using namespace std;

int main() {
  Database myDB;

  // Remember that AddComposer returns a reference to the new record.
  Composer& comp1 = myDB.AddComposer("Ludwig van", "Beethoven", "Romantic", 1770,
    "Beethoven was completely deaf during the latter part of his life - he never "
    "heard a performance of his 9th symphony.");
  comp1.Promote(7);

  Composer& comp2 = myDB.AddComposer("Johann Sebastian", "Bach", "Baroque", 1685,
    "Bach had 20 children, several of whom became famous musicians as well.");
  comp2.Promote(5);

  Composer& comp3 = myDB.AddComposer("Wolfgang Amadeus", "Mozart", "Classical", 1756,
    "Mozart feared for his life during his last year - there is some evidence "
    "that he was poisoned.");
  comp3.Promote(2);

  cout << endl << "all Composers: " << endl << endl;
  myDB.DisplayAll();
}

请注意,这些简单的测试程序只是第一步,不过它们需要我们手动检查输出,以确保程序能正常运行。随着系统规模的扩大,手动检查输出越来越不切实际。 在后续课程中,我们将以单元测试的形式介绍自检测试程序。

我们的应用设计现已完成。下一步是为类和界面实现 .cpp 文件。首先,请将上面的 .h 和测试驱动程序代码复制/粘贴到文件中,然后对其进行编译。使用测试驱动程序来测试您的类。然后,实现以下接口:

Composer Database
---------------------------------------------
1) Add a new composer
2) Retrieve a composer's data
3) Promote/demote a composer's rank
4) List all composers
5) List all composers by rank
0) Quit

使用您在 Database 类中定义的方法实现界面。 确保您的方法能够出错。例如,排名应始终介于 1-10 之间。除非您打算更改 Database 类中的数据结构,否则也不允许任何人添加 101 位 Composer。

请注意,您的所有代码都需要遵循我们的编码规范,为方便起见,我们在此重复说明:

  • 我们编写的每个程序都以标头注释开头,提供作者的姓名、联系信息、简短说明和用法(如果适用)。每个函数/方法都以对操作和用法的注释开头。
  • 当代码本身没有进行记录时(例如,在处理棘手、不明显、有趣或重要的情况下),我们使用完整的句子添加说明注释。
  • 始终使用描述性名称:变量是用 _ 分隔的小写字词,如 my_variable。函数/方法名称使用大写字母来标记单词,如 MyExcitingFunction() 中所示。常量以“k”开头,并使用大写字母来标记单词,例如 kDaysInWeek。
  • 缩进量是二的倍数。第一层是两个空格;如果需要进一步缩进,可使用四个空格、六个空格等。

欢迎来到现实世界!

本单元介绍大多数软件工程组织中都会用到的两个非常重要的工具。第一个是构建工具,第二个是配置管理系统。这两个工具在工业软件工程中都非常重要,在工业软件工程中,许多工程师通常在一个大型系统上工作。这些工具有助于协调和控制对代码库的更改,并提供从许多程序和头文件编译和关联系统的有效方法。

Makefile

程序的构建过程通常由构建工具管理,该工具会按正确的顺序编译和关联所需的文件。C++ 文件常常具有依赖关系,例如,在一个程序中调用的函数位于另一个程序中。或者,多个不同的 .cpp 文件可能需要头文件。构建工具会根据这些依赖项确定正确的编译顺序。它也将仅编译自上次构建后更改的文件。在包含数百或数千个文件的系统中,这样可以节省大量时间。

我们通常使用一种名为 make 的开源构建工具。如需了解详情,请仔细阅读这篇文章。 查看是否可以为 Composer 数据库应用创建依赖关系图,然后将其转换为 makefile。点击此处查看我们的解决方案。

配置管理系统

工业软件工程中使用的第二个工具是配置管理 (CM)。用于管理变更。假设 Bob 和 Susan 都是技术文档作者,都致力于更新技术手册。在会议期间,他们的经理为每位参会者分配了同一文档中的一个部分要更新的部分。

技术手册存储在小鲍和苏珊都可以访问的计算机上。如果没有适当的 CM 工具或流程,可能会出现一些问题。一种可能的情景是,对存储文档的计算机进行设置,使 Bob 和 Susan 不能同时处理手册。这会大大降低运行速度。

如果存储计算机允许 Bob 和 Susan 同时打开文档,则会出现更危险的情况。可能的结果如下:

  1. 小鲍在他的计算机上打开文档,开始处理自己的部分。
  2. 小苏在计算机上打开了文档,开始编辑自己的小节。
  3. 小鲍完成更改并将文档保存在存储计算机上。
  4. 苏珊完成了更改并将文档保存在存储计算机上。

此插图显示了如果单份技术手册上没有控件可能会出现的问题。Susan 保存更改后,会覆盖 Bob 所做的更改。

这正是 CM 系统能够控制的情况。有了 CM 系统,Bob 和 Susan 都“查阅”了各自的技术手册副本并共同努力。当 Bob 再次检查更改时,系统知道 Susan 已检出自己的副本。当 Susan 签入副本时,系统会分析 Bob 和 Susan 所做的更改,并创建一个新版本将这两组更改合并在一起。

除了上述管理并发更改之外,CM 系统还有许多功能。许多系统会存储文档所有版本的归档(从文档最初创建时起算)。对于技术手册,如果用户拥有旧版手册并且正在向技术文档工程师提问,这会非常有用。CM 系统将允许技术文档作者访问旧版本,并能够查看用户看到的内容。

CM 系统对于控制对软件所做的更改尤其有用。此类系统称为软件配置管理 (SCM) 系统。考虑到某个大型软件工程组织内包含大量单独的源代码文件,也考虑有大量工程师必须对这些文件进行更改,那么很明显,SCM 系统至关重要。

软件配置管理

SCM 系统的构建原理很简单:文件的确定副本都保存在中央代码库中。人们从代码库中签出文件的副本,处理这些副本,完成后再重新签入。SCM 系统由多人针对单个主集管理和跟踪修订版本。

所有 SCM 系统都提供以下基本功能:

  • 并发管理
  • 版本控制
  • 同步

我们来详细了解一下每项功能。

并发管理

并发是指多个人同时编辑一个文件。我们希望人们能够通过大型存储库来做到这一点,但这可能会带来一些问题。

举个工程领域的一个简单示例:假设我们允许工程师在一个中央源代码代码库中同时修改同一个文件。Client1 和 Client2 需要同时对文件进行更改:

  1. Client1 会打开 bar.cpp。
  2. Client2 会打开 bar.cpp。
  3. Client1 更改该文件并保存。
  4. Client2 更改文件并保存它,以覆盖 Client1 的更改。

显然,我们不希望这种情况发生。即使我们通过让两位工程师分别处理各自的副本而不是直接在母集上进行处理(如下图所示)来控制这种情况,副本也必须在某种程度上协调一致。大多数 SCM 系统通过允许多个工程师签出(“同步”或“更新”)文件并根据需要进行更改来处理此问题。然后,当文件重新签入(“提交”或“提交”)到代码库时,SCM 系统运行算法来合并更改。

这些算法可以很简单(需要工程师解决有冲突的更改),也可以不那么简单(确定如何智能地合并存在冲突的更改,并且仅在系统确实卡住时才询问工程师)。

版本控制

版本控制是指跟踪文件修订版本,以便重新创建(或回滚到)文件的先前版本。为此,您可以在将每个文件签入代码库时为其创建一个归档副本,也可以保存对某个文件所做的每项更改。我们可以随时使用归档或更改信息来创建之前的版本。版本控制系统还可以创建日志报告,其中包含更改的签入者、签入时间和更改内容。

同步

对于某些 SCM 系统,系统会将单个文件签入或签出代码库。功能更强大的系统可让您一次检出多个文件。工程师可查看自己的完整代码库(或其中一部分)副本,并根据需要处理文件。然后,他们会定期将自己的更改提交回主代码库,并更新自己的个人副本以及时了解其他人所做的更改。此过程称为同步或更新。

Subversion(转换)

Subversion (SVN) 是一个开源版本控制系统。它具有上述所有功能。

当发生冲突时,SVN 会采用简单的方法。冲突是指两位或更多工程师对代码库的同一区域进行了不同的更改,然后都提交了更改。SVN 只会提醒工程师发生冲突,由工程师来解决。

在本课程中,我们将使用 SVN 来帮助您熟悉配置管理。此类系统在行业中非常常见。

第一步是在系统上安装 SVN。点击此处了解相关说明。找到您的操作系统,并下载相应的二进制文件。

一些 SVN 术语

  • 修订版本:一个文件或一组文件的变化。修订版本是不断变化的项目中的一个“快照”。
  • 代码库:SVN 在其中存储项目完整修订历史记录的主副本。 每个项目都有一个代码库。
  • 工作副本:工程师对项目进行更改的副本。一个给定项目可以有多个工作副本,每个副本由一名工程师拥有。
  • 签出:从存储区请求工作副本。工作副本等于项目签出时的状态。
  • 提交:将工作副本中的更改发送到中央代码库中。也称为“签到”或“提交”。
  • 更新:将代码库中其他人的更改导入您的工作副本,或指明您的工作副本是否包含任何未提交的更改。如上所述,这与同步相同。因此,更新/同步会使您的工作副本与代码库副本保持同步。
  • 冲突:两名工程师尝试将更改提交到文件的同一区域的情况。SVN 表示冲突,但工程师必须予以解决。
  • 日志消息:您在提交修订版本时附加到修订版本的注释,用于描述您的更改。日志提供项目中发生的情况的摘要。

安装 SVN 后,我们将执行一些基本命令。首先要在指定的目录中设置代码库。以下是命令:

$ svnadmin create /usr/local/svn/newrepos
$ svn import mytree file:///usr/local/svn/newrepos/project -m "Initial import"
Adding         mytree/foo.c
Adding         mytree/bar.c
Adding         mytree/subdir
Adding         mytree/subdir/foobar.h

Committed revision 1.

import 命令会将目录 mytree 的内容复制到代码库中的目录项目中。我们可以使用 list 命令查看代码库中的目录

$ svn list file:///usr/local/svn/newrepos/project
bar.c
foo.c
subdir/

导入操作不会创建有效副本。为此,您需要使用 svn checkout 命令。此命令将创建目录树的有效副本。现在,我们开始吧:

$ svn checkout file:///usr/local/svn/newrepos/project
A    foo.c
A    bar.c
A    subdir
A    subdir/foobar.h
…
Checked out revision 215.

现在,您已经有了工作副本,可以更改其中的文件和目录了。您的工作副本与任何其他文件和目录集合一样 - 您可以添加新文件、进行修改、移动文件,甚至可以删除整个工作副本。请注意,如果您复制和移动工作副本中的文件,请务必使用 svn copysvn move,而非使用操作系统命令。要添加新文件,请使用 svn add;如需删除文件,请使用 svn delete。如果您只想编辑,只需使用编辑器打开文件并进行编辑即可!

有一些标准目录名称经常与 Subversion 配合使用。“主干”目录包含项目开发的主线。“branches”目录包含您可能正在使用的任何分支版本。

$ svn list file:///usr/local/svn/repos
/trunk
/branches

因此,假设您对工作副本进行了所有必要的更改,并希望将其与代码库同步。如果代码库的该区域有很多其他工程师正在工作,请务必及时更新您的工作副本。您可以使用 svn status 命令查看所做的更改。

A       subdir/new.h      # file is scheduled for addition
D       subdir/old.c        # file is scheduled for deletion
M       bar.c                  # the content in bar.c has local modifications

请注意,状态命令中有很多标志用于控制此输出。 如果要在修改后的文件中查看具体更改,请使用 svn diff

$ svn diff bar.c
Index: bar.c
===================================================================
--- bar.c	(revision 5)
+++ bar.c	(working copy)
## -1,18 +1,19 ##
+#include
+#include

 int main(void) {
-  int temp_var;
+ int new_var;
...

最后,使用 svn update 命令从代码库更新工作副本。

$ svn update
U  foo.c
U  bar.c
G  subdir/foobar.h
C  subdir/new.h
Updated to revision 2.

因为这是可能发生冲突的地方。在上面的输出中,“U”表示未对这些文件的代码库版本进行任何更改,并且已完成更新。“G”表示已合并。代码库版本已更改,但这些更改与您的版本不冲突。“C”表示存在冲突。这意味着代码库中的更改与您的更改重叠,现在您必须在它们之间进行选择。

对于存在冲突的每个文件,Subversion 会将三个文件放在您的工作副本中:

  • file.mine:这是在您更新工作副本之前存在于工作副本中的文件。
  • file.rOLDREV:这是在进行更改之前从代码库中签出的文件。
  • file.rNEWREV:此文件是代码库中的当前版本。

您可以通过以下三种方式之一来解决这种冲突:

  • 浏览文件并手动合并。
  • 将 SVN 创建的一个临时文件复制到您的工作副本版本。
  • 运行 svnrenew 以舍弃所有更改。

解决冲突后,请通过运行 svnresolve 告知 SVN。 此操作会移除这 3 个临时文件,并且 SVN 不会再看到处于冲突状态的文件。

最后要做的就是将最终版本提交到代码库。您可以通过 svn commit 命令执行此操作。提交更改时,您需要提供日志消息,用于描述更改。此日志消息会附加到您创建的修订版本。

svn commit -m "Update files to include new headers."  

关于 SVN 及其如何为大型软件工程项目提供支持,还有很多内容需要。网络上有很多资源 - 只需在 Google 上搜索“Subversion”即可。

在练习时,您可以为 Composer 数据库系统创建一个代码库,然后导入所有文件。然后,签出有效的副本并执行上述命令。

参考

在线 Subversion 书籍

维基百科中关于 SVN 的文章

Subversion 网站

应用:剖析研究

请查看德克萨斯大学奥斯汀分校的eSkeletons