OpenDSA 完整目录

Chapter 0 Introduction to Software Design

| 关于   «  7. 图片与 for-each 循环   ::   目录   ::   9. 使用列表与嵌套 for 循环对对象分组  »

8. 聚合、字符串与更多循环

8.1. 面向对象设计:聚合、组合与委托

随着我们构建越来越复杂的程序,我们需要多个对象来对问题建模。我们的对象需要以受控的方式协同工作。谈到对象协同工作时,面向对象设计中有三个关键术语:

  • 聚合(Aggregation)

  • 组合(Composition)

  • 委托(Delegation)

一个对象可以拥有引用其他对象的字段,以便与它们通信。 例如,一个岛屿知道自己上面的所有 Jeroo(以及花和网)。类似地,表示某门课程的对象可能拥有跟踪教师和学生的字段。

聚合(Aggregation):当一个对象通过其字段"知道"了其他对象,从而能够与该对象通信时,我们称之为"聚合"。 这些其他对象本身可能是有意义或有用的,并且可以在第一个对象"外部"被访问。 通常,这些其他对象会在其构造方法中或通过 setter 方法被传入第一个对象。

组合(Composition):另一种情况是,一个对象可以拥有引用代表其组成部分的其他对象的字段,我们称之为"组合"。例如, 一辆汽车包含发动机、变速箱和排气系统。类似地,一台自动售货机包含控制面板、商品补货系统、商品出货器和找零器。

在组合中,主要对象 包含 或 拥有 它的各个组成部分。 这些组成部分通常是在构造方法内部使用 new 创建的。 它们通常不会由对象外部提供。 我们把这个较大的对象看作在某种意义上由这些组成部分 构建而成 的。

对于聚合和组合,主要对象都可以要求其关联对象执行动作。这可以通过让主要对象在它所知道的对象上调用方法来实现。 有时,主要对象上的某个方法只是通过调用某个较低层对象上的方法来"转交"出去。这被称为 委托(delegation): 通过调用另一个对象的某个方法,请它来完成(部分)工作。

8.2. 字符串与字符

8.2.1. 字符

到目前为止,本课程中我们一直在存储和处理各种类型的数据。我们用 int 和 double 来存放数字,用 boolean 来存储 true 和 false 值。字符(char)是一种新类型,可以存储单个字母、标点、符号、空格、制表符等。

要创建字符变量,我们可以像这样声明并实例化一个变量:

char letterA = 'a';

这里使用 Java 类型 char 声明了一个名为 letterA、可存放单个字符的变量,并把该变量初始化为一个小写 "a"。注意,我们用单引号(')把要作为程序文本一部分的字符字面量括起来。有些程序员把 char 这个类型的读音读成 "care",也就是 "character" 一词的第一个音节。另一些人则按拼写把它读成 "char",就像 "char-broiled" 里的 "char"。这两种读音你都会听到,哪一种都可以接受(甚至还有 更多读法)。

字符字面量(如 'a')出现在单引号中。与出现在双引号中的字符串字面量不同,字符字面量只能包含单个字符。

字符的用法与迄今为止我们看到过的其他数据类型类似。你可以用关系运算符比较它们:

if (letter == 'a')
{

}

自增和自减运算符可以用于字符。因此下面的代码可以工作:

char letter = 'a';
letter = letter + 1;  // now holds the value 'b'

Java 使用 Unicode 来表示字符,因此串可以存储其他字母体系(如西里尔文和希腊文)以及非字母体系语言(如中文)的文本。你可以 在这里阅读更多相关内容。

在 Unicode 中,每个字符都由一个"码元"(code unit)表示,你可以把它当作(有时也确实当作)一个整数来看待。

8.2.2. 字符串

出现在引号中的短语被称为 字符串,因为它们包含一个由"字符"串在一起组成的序列。字符可以是字母、数字、标点符号、符号、空格、制表符等。String 被设计为存储文本,可以被看作一串字符。String 还提供了用于操作字符数据的方法。虽然在 Java 中单个字符是基本类型数据值,但 String 实际上是一个类的名字,String 实际上是对象。

假设我们想创建一个 String 变量。我们可以像这样创建一个:

String food = "banana";

这个例子会创建一个名为 food 的 String 变量,它引用单词 "banana"。

我们还可以这样做来创建一个空串(其中不包含任何字符):

String food = "";

这个例子将用一个表示空串的对象来初始化变量。空串的字面值是 ""——即一对不包含任何字符的双引号。

8.2.3. 访问字符串中的单个字符

字符串提供了一种名为 charAt() 的方法,用于提取单个字符。它返回一个 char (而不是由多个字符组成的字符串)。

String fruit = "banana";
char letter = fruit.charAt(0); // stores just the letter b

实参 0 表示我们想要位置 0 处的字母。与上周的 Pixel 和 Picture 一样,串中的第一个字母存储在位置(或索引)0,而不是 1。在 Java 中,所有条目序列都从位置 0 开始。

8.2.4. 字符串是不可变的

字符串提供了 toUpperCase() 和 toLowerCase() 方法,可将字符串中的所有字母转换为指定的大写或小写形式。这些方法常常令人困惑,因为它们听起来像是会修改字符串。但实际上,这些方法以及其他任何方法都无法改变字符串,因为字符串是 不可变的——这意味着对象的值无法被更改。实际上,所有字符串都是只读对象,字符串上的方法会返回新的 String 对象作为结果,而不会修改原始对象。

当你对某个串调用 toUpperCase() 时,会得到一个作为返回值的新的 String 对象。例如:

String name = "Ada Lovelace";
String nameUpper = name.toUpperCase();

运行这些语句之后,nameUpper 引用字符串 "ADA LOVELACE"。 但 name 仍然引用 "Ada Lovelace"。这两个变量引用两个完全独立的对象。

(如果我们愿意)解决这个问题的一种方法是改变 name 变量所引用的内容。例如:

String name = "Ada Lovelace";
name = name.toUpperCase();

需要说明的是,运行 toUpperCase() 并不能改变字符串的值,仍然存在两个独立的对象。但我们 可以 改变变量所引用的内容,把它赋值为引用原文本的大写版本。这样做之后,我们就不再有任何方式引用原来大小写混合的 String 对象,但如果我们不再需要它,就可以让这个名字改为引用新对象。

另一个有用的 String 方法是 replace(),它会查找并替换一个串中出现的另一个串的实例。

String text = "Computer Science is fun!";
text = text.replace("Computer Science", "CS");

这个例子演示了使用 String 方法的一种常见方式。它调用 text.replace(),后者返回对新串 "CS is fun!" 的引用。然后它把这个新串赋给变量 text,更新该变量使其引用新对象。

这次赋值很重要;如果你不保存返回值,调用 text.replace() 看起来将没有任何效果,因为变量 text 会继续引用原来的(未改变的)对象。

8.2.5. 查看字符串

Jeroo 和 Pixel 是讲解对象是什么、以及对象如何通过运行方法发生变化的一种非常方便的方式。与这些对象不同,我们没有一种图形化的方式来即时观察 String 对象的内容。相反,如果我们想看到某个变量表示什么值,就必须编写一些代码。我们可以使用所谓的打印语句:

System.out.print("Hello World");

当这段代码在 BlueJ 中运行时,它会产生文本输出。BlueJ 会弹出它的 终端窗口(terminal window),显示你的代码产生的文本输出。你应该会看到这个窗口弹出并显示文本 "Hello World"(不含引号)。值得注意的是,如果你运行下面这段代码,可能得不到预期的结果:

System.out.print("Hello");
System.out.print("World");

这会在同一行显示 "HelloWorld",中间没有任何空格。通常,如果不同的串位于不同的行,输出会更容易阅读。为此,我们需要把打印语句从 print 稍微改成 println。

System.out.println("Hello");
System.out.println("World");

这会让 "Hello" 和 "World" 分别打印在不同的行上。

System.out.println() 会追加一个称为 换行符(newline) 的特殊字符,它标记一行的结束,并把后续输出移到下一行的开头。如果你不想要结尾的换行符,或者想把许多信息合并到同一行,可以使用 print() 而不是 println()。

8.2.6. 作用于字符串的运算符

一般来说,你不能对字符串执行数学运算,即使字符串看起来像一个数字。下面的表达式是非法的:

// None of these work!
"Hello" - 1;
"World" / 3;
"Hello" * "World";

注意,有时你会看到看起来像数字的字符串。但任何时候只要看到引号,Java 就会把里面的数据当作字符串处理,所以下面的代码同样无法工作:

// these won't work either
"123" - 1;
"562" / 4;
"99" * "2";

然而,+ 运算符可以用于字符串,但它所做的可能不是你所期望的。对于字符串,+ 运算符执行 拼接(concatenation),也就是把两个串首尾相连。因此 "Hello, " + "World!" 产生串 "Hello, World!"。

这在变量上同样成立:

String firstName = "Katie";
String lastName = "Brian";
String space = " ";

// create a string that will store the value "Katie Brian"
String firstAndLast = firstName + space + lastName;

由于 + 对数字和字符串都有定义,Java 会执行一些你可能意想不到的自动转换:

System.out.println(1 + 2 + "Hello");
// produces: 3Hello

System.out.println("Hello" + 1 + 2);
// produces: Hello12

Java 从左到右执行这些操作。在第一行中,1 + 2 是 3,3 + "Hello" 是 "3Hello"。但在第二行中,"Hello" + 1 是 "Hello1","Hello1" + 2 是 "Hello12"。

拼接在许多情况下都会派上用场。例如,如果你在双引号内有一长串字符,请注意字符串字面量在程序中不能跨两行:

// Will not compile!
String long = "This is a really
long line.";

相反,包围字符串字面量的双引号必须都出现在同一行。不过,你仍然可以编写长字符串。只要使用拼接即可:

String long = "This is a really "
    + "long line.";

如果你有一个太长而难以编写的字符串,就把它拆成片段,使每个片段可以单独占一行,然后用 + 把它们组合起来。

8.2.7. 常用字符串方法

下面是一些常见且可能用得上的字符串方法。

MethodDescriptionExample
int length() Get the number of characters in the string.
int len = str.length();
boolean equals(Object other) Check to see if two strings have the same contents. Do NOT use ==, use equals().
if (str1.equals(str2))
{
    ...
}
boolean startsWith(String prefix) Check to see if one string begins with another (i.e., the other is a prefix of this string).
if (str1.startsWith(str2))
{
    ...
}
boolean endsWith(String suffix) Check to see if one string ends with another (i.e., the other is a suffix of this string).
if (str1.endsWith(str2))
{
    ...
}
String substring(int start, int end) Produce a new string consisting of a subsequence of characters, starting at position start and continuing up to but not including position end.
String sub = str.substring(3, 5);
String substring(int start) Produce a new string consisting of a subsequence of characters, starting at position start and continuing through all remaining characters (just as if you provided length() as the ending position).
String sub = str.substring(3);
int indexOf(char target) Search for the specified character starting from the beginning of the string, and return the position of the first occurrence, or -1 if it is not found.
int pos = str.indexOf('a');
String toLowerCase() Generate a new string containing the same content as this string, but with all characters replaced with their lowercase versions.
String lower = str.toLowerCase();
String toUpperCase() Generate a new string containing the same content as this string, but with all characters replaced with their uppercase versions.
String upper = str.toUpperCase();

8.2.8. 子串

子串(substring) 是字符串内的一个字符子序列——这是对"字符串的一部分"的一种绕口的说法。 substring() 方法返回一个新串,从给定的索引开始复制既有串中的字母。

String fruit = "banana";
String sub1 = fruit.substring(0); // returns "banana"
String sub2 = fruit.substring(2); // returns "nana"
String sub3 = fruit.substring(6); // returns ""

第一个示例返回整个串的副本,因为它从位置 0 开始。第二个示例返回除前两个字符之外的所有字符(它从位置 2 开始,也就是第三个字母)。正如最后一个示例所示,如果实参是串的长度,substring() 返回空串。

substring() 还有其他带不同参数的版本。如果用两个实参调用它,这两个实参会被当作开始索引和结束索引:

String fruit = "banana";
String part1 = fruit.substring(0, 3); // returns "ban"
String part2 = fruit.substring(2, 5); // returns "nan"

注意,结束索引所指的字符不被包含在内。substring() 方法包含从起始位置开始的所有字符,一直到但不包括结束位置。

这样定义 substring() 简化了一些常见操作。例如,要选择从索引 i 开始、长度为 len 的子串,你可以这样写:

String result = fruit.substring(i, i + len);

8.3. 计数控制循环

到目前为止,我们使用过 while 循环和 for-each 循环。

当你需要知道何时停止、但不知道要达到那个结束状态需要多少次迭代时,while 循环非常合适:

Jeroo jer = new Jeroo();
while (!jer.seesWater(AHEAD))
{
    jer.hop();
}

无论岛屿多大,Jeroo 都会一直跳跃,直到它碰到水为止。

当你想处理一组中的每个条目(比如想改变图片中每个像素的值)时,for-each 循环非常合适:

Picture image = new Picture("image.png");
for (Pixel pix: image.getPixels())
{
    pix.setRed(0);
}

但是,如果我们想按一定次数执行一系列动作呢?虽然这两种循环 能够 完成这件事,但另一种 for 循环更适合这种情况。

for (int i = 0; i < 10; i++)
{
    System.out.println("Looping!");
}

上面的代码会简单地打印 10 次字符串 "Looping!"。

for 循环在圆括号中有三个组成部分,用分号分隔: 初始化器(initializer) 、 条件(condition) 和 更新步(update step) 。

  1. 初始化器(initializer) 在循环一开始运行一次。 最常见的情况是,它引入一个用于控制循环的局部变量。 这个变量常被用作 计数器(counter),在一系列数字间循环变化。

  2. 条件(condition) 在每次循环时都被检查。只要它是 true,循环就继续重复;当它变成 false 时,循环结束。与 while 循环一样,这个条件只在循环顶部被检查,每次重复检查一次。

  3. 在每次迭代结束时,执行 更新步(update step) 把计数器变量移到它的下一个值,然后我们回到第 2 步。

for 循环通常更易读,因为它把与循环相关的所有语句都放在循环顶部。与 while 循环只提供容纳条件的位置不同,它提供了初始化预期控制循环的变量的位置,以及在循环的每次迭代中把该变量更新为新值的位置。

for 循环和 while 循环有一个区别:如果你在初始化器中声明一个变量,它只存在于 for 循环体内。例如,下面的代码会产生错误:

for (int i = 0; i < 10; i++)
{
    System.out.println("Looping!");
}
System.out.println(i);   // would cause an error

最后一行尝试显示变量 i 的值(除了演示之外没有别的原因),但它无法工作。如果你需要在循环外部使用循环变量,就必须在循环外声明它,就像这样:

int i = 0;
for (i = 0; i < 10; i++)
{
    System.out.println("Looping!");
}
System.out.println(i);   // would NOT cause an error

这些循环中的更新步使用记号 i++,即一元 自增运算符(increment operator) ++。这个运算符是"给这个变量加一"的简写,与 i = i + 1 相同。像 i = i + 1 这样的赋值不会经常出现在 for 循环中,因为 Java 提供了更简洁的加一和减一方式。++ 与 i = i + 1 效果相同,而 -- 是 自减运算符(decrement operator) ;它减一,与 i = i - 1 效果相同。如果你想按 1 以外的量自增或自减某个变量,可以使用 += 和 -=。例如,i += 2 把 2 加到变量 i 上。

这很重要,因为你最常见的 for 循环通常长这样:

for (int i = 0; i < 10; i++)
{
    // body of loop
}

同样,这个循环中的变量 i 从值 0 开始,每次迭代增加一,直到它增加到 10,此时条件(i < 10)不再为 true。

相反,如果我们想倒过来,让循环从 10 开始递减:

for (int i = 10; i > 0; i--)
{
    System.out.println(i + "...");
}
System.out.println("Blast off!");

这个循环会打印从 10 开始倒数的数字,然后打印短语 "Blast off!"。

8.3.1. 遍历字符串

你可以用 for 循环来遍历字符串中的字符。下面的循环遍历 fruit 中的字符并显示它们,每个字符占一行:

for (int i = 0; i < fruit.length(); i++)
{
    char letter = fruit.charAt(i);
    System.out.println(letter);
}

字符串提供一个名为 length() 的方法,用于返回字符串中的字符数量。由于它是一个方法,你必须使用空参数列表来调用它,即 () 。

条件是 i < fruit.length(),这意味着当 i 等于串的长度时,条件为 false,循环终止。

要找串的最后一个字母,你可能会忍不住尝试类似这样的写法:

String fruit = "banana";
int length = fruit.length();
char last = fruit.charAt(length); // wrong!

这段代码可以编译并运行,但调用 charAt() 方法会抛出 StringIndexOutOfBoundsException。问题在于 "banana" 的索引 6 处没有字符。由于位置从 0 开始,这 6 个字母的索引是从 0 到 5。要得到最后一个字符,你必须从长度中减去 1。

String fruit = "banana";
int length = fruit.length();
char last = fruit.charAt(length - 1); // correct!

这是需要记住的重要一点,因为在使用 for 循环遍历串时,这是一个常见错误:

String fruit = "banana";
for (int i = 0; i <= fruit.length(); i++) // wrong!
{
    ...
}

我们需要把 for 循环中的条件写成 i < fruit.length() 或 i <= fruit.length() - 1 二者之一。在 Java 中, 更常见得多 的是看到用 < 编写循环,而这种比较方式与从 0 开始的位置更自然地契合。

许多字符串遍历都涉及读取一个串并创建另一个串。例如,要反转一个串,我们只需一次添加一个字符:

public String reverse(String s)
{
    String result = "";
    for (int i = s.length() - 1; i >= 0; i--)
    {
        result = result + s.charAt(i);
    }
    return result;
 }

result 的初始值是 "",即 空串(empty string)。循环按相反顺序遍历 s 的字母。每次通过循环,它都创建一个比原来长一个字符的新串,并把它赋给 result。循环退出时,result 按相反顺序包含来自 s 的字母。所以 reverse("banana") 的结果是 "ananab"。

8.4. 随机数小贴士

8.4.1. 生成随机数

有时你可能会发现,在那些你希望选择更加独立、多变的一些程序中,作出随机选择会有所帮助。Java 提供了一个名为 Random 的内置类来生成随机数,我们的学生类库中包含这个类的一个对初学者有帮助的特殊版本。要使用这个类,请在文件顶部添加下面的 import 语句:

import student.util.Random;

Random 类提供了名为 generator() 的方法来获取一个表示随机数生成器的对象。在这里,我们只需要处理随机整数的生成,而生成器正是为此目的提供了一个非常有用的方法。你可以像这样使用它:

Random generator = Random.generator();   // local variable to refer to the random number generator
int value = generator.nextInt(4);        // generate a random number from 0 - 3

该生成器提供了一个名为 nextInt() 的方法来生成一个随机整数。它接受一个参数,即上限。当你提供这个上限时,nextInt() 方法会生成一个从 0(含)到(但不含)上限之间的数字。

所以,例如,如果你想生成一个 0-99 之间的数字,就要调用 nextInt(100)。

假设你希望某个动作 15% 的时间被执行。你可以这样做:

Random generator = Random.generator();   // local variable to refer to the random number generator
int value = generator.nextInt(100);
if (value < 15)
{
    //code in here will happen 15% of the time
}

在这里,对 nextInt() 的调用会产生一个 0-99 之间的数字(也就是 100 个可能的值),如果生成的数字在 0-14 范围内(也就是 15 个可能的值,即 15% 的时间),if 语句就会执行它的真分支。

8.4.2. 测试随机行为

随机行为非常适合基于概率的事件。但随机行为也让软件测试变得困难。当你为代码添加随机行为然后再测试它时,你的测试用例该怎么办?假设你希望你的 Actor 在某种特定情境下有一半时间向左转,另一半时间向右转。如果你编写一个 Actor 处于那种情境的测试用例,它可能会向左转……也可能不会。那你该如何为它编写测试?

答案很简单:Random 类可以帮你。考虑下面的代码序列,它生成三个小于 100 的随机数:

// using the same local variable "generator" from before
int x = generator.nextInt(100);
int y = generator.nextInt(100);
int z = generator.nextInt(100);

编写使用这段代码的测试用例会很困难,因为你无法控制最终进入 x、y 和 z 的值。然而,对于测试用例,Random 类提供了一个名为 setNextInts() 的特殊方法,让你可以控制为测试目的而生成了哪些数字。你可以这样使用它:

// In your test case, do this:
Random.setNextInts(40, 50, 60);

// In the code you are testing, this happens:
int x = generator.nextInt(100);
int y = generator.nextInt(100);
int z = generator.nextInt(100);

// You know x will get the value 40, while y is 50, and z is 60

因此,当你在测试随机的行为时,只需在测试用例中说明你希望随机数生成器产生的值序列,就可以强制这些动作变得可预测。在测试用例之外,生成器会产生真正的(伪)随机序列,但在你的测试用例内部,这些数字将完全由你决定。

8.5. 方法重写

Java 类层次结构中最通用的类是 java.lang.Object 类。它是 Java 程序中出现的所有类的超类。默认情况下,它是任何没有在其类定义中显式指定其他类的类的直接超类。

Object 类中最有用的方法之一是 toString() 方法:

public class Object
{
    public String toString();
}

toString() 方法返回其对象的 String 表示,但这些表示不一定总是有用。例如,如果我们运行:

Jeroo jerr = new Jeroo();
System.out.println(jerr.toString());

我们会看到类似 Jeroo@5f93274e 的输出(不过如果你运行它,可能会看到一组不同的数字和字母)。

这个实验表明,toString() 的默认定义返回其对象的某种内部表示。看起来它返回的是对象类名与其内存地址拼接的结果。这对某些应用可能有用。但对于大多数对象,我们会希望重写默认定义,让 toString() 方法返回一个对我们正在处理的任何对象都更合适的字符串。

事实上,每个对象总是有你可以在其上使用的 toString() 方法。在打印语句中使用 toString() 是如此常见,以至于当你直接对对象调用 println() 时,toString() 会被自动调用。

Jeroo anna = new Jeroo();
System.out.println(anna);  // calls toString() automatically

8.5.1. 重写 toString() 方法

在本例中,让我们考虑一个表示 ATM 的类,它跟踪自己的位置和机器内的金额。

public class ATM
{
    private String location;
    private double moneyInside;

    public ATM(String where, double amount)
    {
        this.location = where;
        this.moneyInside = amount;
    }
}

再让我们创建这个类的一个对象。这台 ATM 将位于银行,里面有 4000.00 美元(USD)。

ATM bankATM = new ATM("bank", 4000.00);
System.out.println(bankATM);
// output: ATM@149f5b42

打印 bankATM 会在 ATM 上自动调用 toString(),但我们在输出中看到的是 toString() 的默认行为,它对于告诉我们关于这台机器的任何信息都没什么帮助。

相反,让我们编写一个能更好地告诉我们这台 ATM 位于何处的 toString() 方法。

由于每个类都已经内置了 toString() 方法,我们需要 重写(override) 该方法,用我们自己的行为替换它的行为。要重写一个方法,只需在子类中定义具有相同签名的方法。如果你用子类的实例调用 toString(),将使用该子类版本的方法。这样,子类方法就重写了超类版本。

首先,让我们把上面看到的方法签名添加到 ATM 类中。

public class ATM
{
    private String location;
    private double moneyInside;

    public ATM(String where, double amount)
    {
        this.location = where;
        this.moneyInside = amount;
    }

    public String toString()
    {

    }
}

由于我们正在重写对象固有拥有的 toString() 方法,这个方法签名需要与上面定义的完全一样。接下来,我们将创建一个能告诉我们这台 ATM 在哪里的 String 变量。

public class ATM
{
    private String location;
    private double moneyInside;

    public ATM(String where, double amount)
    {
        this.location = where;
        this.moneyInside = amount;
    }

    public String toString()
    {
        String data = "This ATM is at the: " + this.location;
    }
}

然后,由于 toString() 方法中的返回类型是字符串,我们需要返回那个字符串。

public class ATM
{
    private String location;
    private double moneyInside;

    public ATM(String where, double amount)
    {
        this.location = where;
        this.moneyInside = amount;
    }

    public String toString()
    {
        String data = "This ATM is at the: " + this.location;
        return data;
    }
}

现在让我们再考虑一下我们的 bankATM……

ATM bankATM = new ATM("bank", 4000.00);
System.out.println(bankATM);
// output: "This ATM is at the: bank"

既然我们已经重写了 toString() 方法,就可以看到一些有用的信息了。

虽然这个新方法在 ATM 类中可能不会发挥重要作用,但它确实提供了对对象状态的非常简短、易于理解的描述。这就是 toString() 方法被包含在 Object 类中的原因。

8.6. 语法练习 7a:for 循环与面向对象设计

8.7. 语法练习 7b:toString 与返回值

8.8. 编程练习 7a

8.9. 编程练习 7b

   «  7. 图片与 for-each 循环   ::   目录   ::   9. 使用列表与嵌套 for 循环对对象分组  »

关闭窗口