MSDN 在线教学——使用 C#: 打开包装! 快点!
请访问 MSDN 源代码中心,下载本专栏文章中示例的源代码(英文)。
上个月,我们介绍了装箱和取消装箱的方法,以及什么时候会用到它们。这个月,我们将研究装箱对性能的影响,以及我们应当怎样将这种影响减少到最小。
装箱和性能
由于进行了装箱,所以 C# 中的对象模型非常简单明了。但是采用经过装箱的数值类型会导致性能的降低。在大多数情况下,对象模型的简化比性能的降低更为重要。对于一般的软件而言的确是这样。节省开发和维护软件的时间是最需要进行优化的地方,同时正是这些优化措施能够最大程度地改善程序的性能。
最佳的解决方案可能是使用一个通用的 ArrayList。这样我们可以声明一个 Arraylist<int>,该对象就可以不经过装箱直接存储 int 值。不幸的是,在 C# 的第一版本中没有通用数据类型(尽管人们正研究在后续版本中提供这种功能),所以这种解决方案于事无补。
有时候可以用别的没有装箱损耗的对象来代替。除了利用 ArrayList 来保存 int 值以外,还可以(需要使用较少的额外代码来添加新的元素)使用 int[],或者还可以编写一个 ArrayListInt 来封装这些额外代码。但是,这并不是最简便的解决方案。
还有很多其他的选择。为了更好地对它们进行说明需要举一个具体的实例。
字数统计
这是我以前编写的一个 C# 程序,它可以将一个测试文件分解成一个个的单词,再统计每个单词出现的次数。我写这个程序的目的是为了了解常用表达式类在框架中的工作方式,并将用 C# 编写的程序与用 Perl 编写的类似程序进行比较。
如果您还没有接触过 Perl 的话, 这是一种专门用于文本处理的编程语言,所以特别适用于这样的任务。
最容易想到的、统计单词出现次数的方法是将单词作为关键字,利用“散列表”存储单词出现的次数。代码的内循环如下所示:
while ((line = stream.ReadLine()) != null)
{
foreach (string word in regexSplit.Split(line.ToLower()))
{
int count = 0;
object value = wordTable[word];
if (value != null)
count = (int) value;
wordTable[word] = count + 1;
}
}
在内循环中, 我们从“散列表”中获得某个关键字的当前值。如果该值不是空值,就将它转换成一个 int。随后重新将正确的值存储到“散列表”中。
编写这段代码很容易,但是如果某个单词已经存在的话,它将会造成相当大的性能损耗。仅仅为了累加某个值,我们不仅要对它进行解装箱,还需要为每个字符串计算两次散列码。
尽管会产生这些损耗,但是这段程序的性能仍然是相当不错的。为了获得一些具体的性能指标,我需要使用一些比较合适的文本文件。我首先下载了由 David Weber 撰写的《On Basilisk Station》(英文),其中包括 160,000 个单词。对于一次比较全面的测试而言这个文件显得有些小。用 Perl 编写的程序可以在不到一秒钟的时间里将它处理完毕。随后我从 Project Gutenberg(英文)下载了《战争与和平》(英文),其中包括大约 600,000 个单词。这个要稍好一些。
用 Perl 编写的程序花费了大约 4 秒钟,就完成了对《战争与和平》的字数统计。而使用了被装箱的 int 的 C# 程序花了大约 10 秒钟。每秒钟处理 60,000 个单词已经很不错了,但是我对到底可以将它的速度提高到多少非常感兴趣。
确定基准
在我们开始尝试不同的方法以前,我们需要知道这个 C# 程序的基准时间是多少。换句话说,读取文件的所有行,并将它们分解成一个个的单词而不进行字数统计,这个过程需要多长时间。这样做非常重要,因为只有这样我们才能只比较源代码中的统计部分所用的时间。
如果我们编写实现上述任务的代码,我们发现在大约 7 秒钟的时间内就完成了除了字数统计以外的所有任务。这意味着字数统计部分花费了几秒钟。
让程序运行得更快
我们的目标是取消整型数据的装箱和取消装箱过程。换句话说,我们希望能够不经过对整型数据进行解装箱以及随后再对其重新装箱的过程直接累加“散列表”中的值。要做到这一点,一种显而易见的方法是使用一种引用类型,而不是数值类型。我们可以将整型统计值装箱在类中。这个类非常简单:
class IntHolderClass
{
int count;
public IntHolderClass()
{
count = 1;
}
public int Count
{
get
{
return(count);
}
set
{
count = value;
}
}
public override string ToString()
{
return(count.ToString());
}
}
当为这个类创建一个新的实例时,统计值设为 1。统计值可以通过 Count 属性递增。主循环被修改成如下形式:
foreach (string word in regexSplit.Split(line.ToLower()))
{
wordCount++;
IntHolderClass value = (IntHolderClass) wordTable[word];
if (value == null)
{
wordTable[word] = new IntHolderClass();
}
else
value.Count++;
}
}
如果单词不存在,就创建一个新的装箱类实例,并将其放入“散列表”中。如果单词已存在,就累加其计数。
当我们运行此版本时,我们发现所花时间不到一秒钟。这大约是采用了装箱的程序所花时间的 30%。刚开始我对这个结果有些惊讶,因为表面上看起使用类应当造成更多的损耗。但是经过仔细思考,我们知道,很显然,尽管创建一个被装箱的 int 与创建一个装箱类所造成的损耗是相同的,但是对于被装箱的 int,我们为每个单词(总共有 600,000个)而不是为每个唯一的单词(大约有 19,000 个)创建了一个装箱。
这种技术所带来的性能改善的程度取决于我们在数值保存在集合类中时所执行的操作的次数。如果我们只是找到该对象并将其取出,那么不会带来任何性能上的改善。例如,如果我用一个 ArrayList 来保存用于后续处理的整型值,那么采用这种技术对于性能将没有任何帮助。
当您编写代码时,最好使用尽可能简便的方法。在实现这种方法以后,如果需要更快的速度,则可以再考虑其他的方法来提高运行速度。
另一种技术
还有一种类似的方法可获得同样的结果。数值类型可以作为接口,但是因为接口都是引用类型,所以您只能使用一个引用某个被装箱的数值类型的接口。我们可以定义一个具有 Increment() 成员的接口,并使用该数值类型实现它。利用这种方法,我们可以直接通过被装箱的数值类型获得接口,然后再调用 Increment() 函数,这一切都不需要取消装箱。接口和数值类型如下:
interface IIncrement
{
void Increment();
}
struct IntHolderStruct: IIncrement
{
int value;
public IntHolderStruct(int value)
{
this.value = value;
}
public int Value
{
get
{
return(value);
}
}
public void Increment()
{
value++;
}
public override string ToString()
{
return(value.ToString());
}
}
我很希望宣布这是由我自己独立完成的,但实际上它是我在最近的一次会议上遇到的一个与会者编写的。这种实现的主循环的工作方式与采用装箱类的那段程序非常相似。这个程序运行时所花费的时间大约是采用被装箱的 int 的程序所花时间的 32%。换句话说,它只比采用装箱类的程序稍慢一点。
全部结果
以下内容是对全部结果的总结。
表 1《On Basilisk Station》的结果
实现方式
所用时间
相对于被装箱 Int 的比例
被装箱 Int
0.64
1.00
装箱类
0.28
0.43
装箱结构和接口
0.29
0.45
表 2《战争与和平》的结果
实现方式
所用时间
相对于被装箱 Int 的比例
被装箱 Int
3.01
1.00
装箱类
0.92
0.31
装箱结构和接口
0.97
0.32
各种实现和驱动程序代码可以在示例代码中找到。为了在归档时不至于太大,我在这里省去了这些文本文件。我还引用了一个 Perl 文件作为参考。如果您要运行这个 Perl 程序,您需要从 http://www.activestate.com 站点下载 ActivePerl 软件(这是一个免费软件)。
总结
我希望您喜欢这篇关于装箱的介绍。通常,尽管装箱会导致性能上的一些损耗,但是并没有太大关系,因为相比之下简化更为重要。但是有时候,可能需要使用一个装箱类来减少由于装箱所带来的性能损耗。
网站摸彩袋
在这个摸彩袋中有相当多的站点,所以我使用我的秘密的随机数生成器来挑出其中的五个。它们是:
再次需要指出的是,这些都是没有保证的站点。我所能保证的是当您点击上面的 URL 地址时会显示一个网页。
Eric Gunnerson 是 C# 编译器组的 QA 领导,C# 设计组的成员,以及“A Programmer's Introduction to C#”(英文)一书的作者。他从事编程工作的时间很长,他甚至知道什么是 8 英寸磁盘,还能用一只手装磁带。