计数器代码:
`timescale 1ns / 1ps
module cnt(input clk,input rst,output reg [7:0] cnt,output reg plus );
always@(posedge clk)
begin
if(!rst)
cnt <=0;
else if(cnt==9)
cnt<=0;
else
cnt<= cnt+1;
end
always@(posedge clk)
begin
if(!rst)
plus<=0;
else if(cnt==9)
plus<=1;
else
plus <=0;
end
endmodule
仿真代码:
`timescale 1ns / 1ps
module cnt_tb( );
reg clk;
reg rst;
wire[7:0] cnt;
wire plus;
cnt cnt_init(.clk(clk),.rst(rst),.cnt(cnt),.plus(plus));
initial
begin
clk <=0;
rst <= 0;
#20
rst <=1;
end
always #5 clk =~clk;
endmodule
仿真结果:
总线式38译码器使用的地址线 wire a[2:0],输出为reg类output[7:0]。
源代码如下:
module mut38(input[2:0] a ,output reg [7:0] b);
always@(*)
begin
case(a)
3'b000: b<=8'b0000_0001;
3'b001: b<=8'b0000_0010;
3'b010: b<=8'b0000_0100;
3'b011: b<=8'b0000_1000;
3'b100: b<=8'b0001_0000;
3'b101: b<=8'b0010_0000;
3'b110: b<=8'b0100_0000;
3'b111: b<=8'b1000_0000;
endcase
end
endmodule
认知:
- 模块的信号必须指定方向,如input ,output.
- switch所谓的C语言中的,在verilog中就变成了case 和 endcase了。
仿真代码:
`timescale 1ns / 1ps
module mut38_tb();
reg [2:0] a;
wire[7:0] b;
mut38 mut32_inst(.a(a),.b(b));
initial begin
a<=3'b000;
#20
a<=3'b001;
#20
a<=3'b010;
#20
a<=3'b011;
#20
a<=3'b100;
#20
a<=3'b101;
#20
a<=3'b110;
#20
a<=3'b111;
end
endmodule
认知:
仿真写的临时变量,如reg,wire类型的,不需要input,output,因为只有模块接口需要。
仿真结果如下:
import asyncio
import websockets
async def echo(websocket, path):
async for message in websocket:
await websocket.send('------------'+message)
print(r'start');
start_server = websockets.serve(echo, "localhost", 8765)
asyncio.get_event_loop().run_until_complete(start_server)
asyncio.get_event_loop().run_forever()
在本地端使用HTMLW代码可测试:
<html>
<body>
</body>
<script >
var ws = new WebSocket("ws://localhost:8765");
//申请一个WebSocket对象,参数是服务端地址,同http协议使用http://开头一样,WebSocket协议的url使用ws://开头,另外安全的WebSocket协议使用wss://开头
ws.onopen = function(){
//当WebSocket创建成功时,触发onopen事件
console.log("open");
ws.send("hello"); //将消息发送到服务端
}
ws.onmessage = function(e){
//当客户端收到服务端发来的消息时,触发onmessage事件,参数e.data包含server传递过来的数据
console.log("onmessage");
console.log(e.data);
}
ws.onclose = function(e){
//当客户端收到服务端发送的关闭连接请求时,触发onclose事件
console.log("onclose");
}
ws.onerror = function(e){
//如果出现连接、处理、接收、发送数据失败的时候触发onerror事件
console.log(error);
}
</script>
</html>
always@(posedge clk or posedge reset)
if(reset)
bps_DR <= 16'd5207;
else begin
case(baud_set)
0:bps_DR <= 16'd5207;
1:bps_DR <= 16'd2603;
2:bps_DR <= 16'd1301;
3:bps_DR <= 16'd867;
4:bps_DR <= 16'd433;
default:bps_DR <= 16'd5207;
endcase
end
//波特率计数器
always@(posedge clk or posedge reset)
if(reset)
div_cnt <= 16'd0;
else if(uart_state)
begin
if(div_cnt == bps_DR)
div_cnt <= 16'd0;
else
div_cnt <= div_cnt + 1'b1;
end
else
div_cnt <= 16'd0;
end
// 波特率计数到时,bps_clk触发一个上升沿,用于通知进行下一位
always@(posedge clk or posedge reset)
if(reset)
bps_clk <= 1'b0;
else if(div_cnt == 16'd1)
bps_clk <= 1'b1;
else
bps_clk <= 1'b0;
end
//由bps_clk上升沿触发,位移
always@(posedge clk or posedge reset)
if(reset)
bps_cnt <= 4'd0;
else if(bps_cnt == 4'd11)
bps_cnt <= 4'd0;
else if(bps_clk)
bps_cnt <= bps_cnt + 1'b1;
else
bps_cnt <= bps_cnt;
end
always@(posedge clk or posedge reset)
if(reset)
tx_done <= 1'b0;
else if(bps_cnt == 4'd11) //bps count计数到11时,表示发送完成
tx_done <= 1'b1;
else
tx_done <= 1'b0;
//uart_state=1时表示发送,否则为空闲
always@(posedge clk or posedge reset)
if(reset)
uart_state <= 1'b0;
else if(send_en)
uart_state <= 1'b1;
else if(bps_cnt == 4'd11)
uart_state <= 1'b0;
else
uart_state <= uart_state;
end
//数据设置
always@(posedge clk or posedge reset)
if(reset)
data_byte_reg <= 8'd0;
else if(send_en)
data_byte_reg <= data_byte;
else
data_byte_reg <= data_byte_reg;
end
//数据发送状态机
always@(posedge clk or posedge reset)
if(reset)
uart_tx <= 1'b1;
else begin
case(bps_cnt)
0:uart_tx <= 1'b1;
1:uart_tx <= START_BIT;
2:uart_tx <= data_byte_reg[0];
3:uart_tx <= data_byte_reg[1];
4:uart_tx <= data_byte_reg[2];
5:uart_tx <= data_byte_reg[3];
6:uart_tx <= data_byte_reg[4];
7:uart_tx <= data_byte_reg[5];
8:uart_tx <= data_byte_reg[6];
9:uart_tx <= data_byte_reg[7];
10:uart_tx <= STOP_BIT;
default:uart_tx <= 1'b1;
endcase
end
源代码:
`timescale 1ns / 1ps
module decode(a,b,c,out);
input a;
input b;
input c;
output reg[7:0] out;
always@(*)
begin
case ({c,b,a})
3'b000:out<=8'b00000001;
3'b001:out<=8'b00000010;
3'b010:out<=8'b00000100;
3'b011:out<=8'b00001000;
3'b100:out<=8'b00010000;
3'b101:out<=8'b00100000;
3'b110:out<=8'b01000000;
3'b111:out<=8'b10000000;
endcase
end
endmodule
仿真代码:
`timescale 1ns / 1ps
module decode_tb();
reg a;
reg b;
reg c;
wire[7:0] out;
decode decode_inst(
.a(a),
.b(b),
.c(c),
.out(out)
);
initial begin
a=0;
b=0;
c=0;
#20
a=1;
b=0;
c=0;
#20
a=0;
b=1;
c=0;
#20
a=1;
b=1;
c=0;
#20
a=0;
b=0;
c=1;
#20
a=1;
b=0;
c=1;
#20
a=0;
b=1;
c=1;
#20
a=1;
b=1;
c=1;
end
endmodule
`
仿真结果如下:
LED点灯
module led_flash(
input clk,
input rst_n,
output reg led
);
reg [25:0] cnt;
always @ (posedge clk or negedge rst_n)
begin
if (!rst_n)
cnt <= 26'd0;
else if (cnt < 26'd49_999_999)
cnt <= cnt + 1'b1;
else
cnt <= 26'd0;
end
always @ (posedge clk or negedge rst_n)
begin
if (!rst_n)
led <= 1'b0;
else if (cnt == 26'd49_999_999)
led <= ~led;
else
led <= led;
end
endmodule
用一个特定的大小定义一个vector是完全合法的,
vector<int> v(10); // 建立一个大小为10的vector
而string在很多方面像vector,所以你可能希望可以这么做:
string s(10); // 常识建立一个大小为10的string
这不能编译。string没有带有一个int实参的构造函数。我的一个STL平台像这样告诉我那一点:
example.cpp(20): error C2664:'__thiscall std::basic_string<char, struct
std::char_traits<char>,class std::allocator<char> >::std::basic_string<char,
struct std::char_traits<char>,class std::allocator<char> >(const class
std::allocator<char> &)': cannot convert parameter 1 from 'const int' to 'const
class std::allocator<char> &' Reason: cannot convert from 'const int' to 'const
class std::allocator<char>
No constructor could take the source type, or constructor overload resolution was ambiguous
是不是很奇妙?消息的第一部分看起来像一只猫走过键盘,第二部分神秘地提到了一个从未在源代码中涉及的分配器,第三部分说构造函数调用是错的。当然,第三部分是准确的,但首先让我们关注于号称猫咪散步的结果上,因为当使用string时,这是你经常遇到的诊断信息的典型。
string不是一个类,它是typedef。实际上,它是这个的typedef:
basic_string<char, char_traits<char>, allocator<char> >这是因为字符串的C++观念已经被泛化为表示带有任意字符特性(“traits”)的任意字符类型的序列并储存在以任意分配器分类的内存中。在C++里所有类似字符串的对象实际上都是basic_string模板的实例,这就是为什么当大多数编译器发出关于“程序错误使用string”的诊断信息时会涉及类型basic_string。(一些编译器很善良,在诊断信息中会使用string的名字,但大多数不会。)通常,那样的诊断信息会明确指出basic_string(以及服务助手模板char_traits和allocator)在std名字空间里,所以常常看到错误调用string会产生提及这种类型的诊断信息:
std::basic_string<char, std::char_traits<char>, std::allocator<char> >
这十分接近于上面编译器里使用的诊断信息,但不同的编译器使用这个主题的不同变体。我使用的另一个STL平台以这种方式表示string,
basic_string<char, string_char_traits<char>, default_alloc_template<false,0> >string_char_traits和default_alloc_template的名字是非标准的,但是那是生活。一些STL实现背离了标准。如果你不喜欢你当前STL实现里的背离,考虑用另一个来替换它。条款50给了你可以找到可选择实现的位置的例子。
不管编译器诊断信息怎样表示string类型,把诊断信息减少到有意义的东西的技术是一样的:用文字“string”全局替换冗繁难解的basic_string。如果你使用的是命令行编译器,通常可以很容易地用一个类似sed的程序或一种脚本语言比如perl、python或ruby来完成。(你可以在Zolman的文章——《Visual C++的STL错误信息解码器》[26]——里找到一个这样的脚本的例子。)就上面的诊断信息而言,我们用string全局替换
std::basic_string<char, struct std::char_traits<char>,
class std::allocator<char> >
可以得到这个:
example.cpp(20): error C2664:'__thiscall string::string(const class
std::allocator<char> &)': cannot convert parameter 1 from 'const int' to const
class std::allocator<char> &'
这会清楚(或至少比较清楚)地说明问题是在传给string构造函数的参数类型里,即使仍然神秘地提及allocator<char>,但比较容易使人发现不存在只带有大小的string构造函数形式。
顺便说一下,神秘地提到分配器的原因是每个标准容器都有一个只带有分配器的构造函数。就string而论,是三个可以用一个实参调用的构造函数之一,但由于某种原因,编译器指出带有分配器的那个是你试图调用的。编译器指错了,而诊断信息也令人误解。哦哟。
至于只带有分配器的构造函数,请不要使用它。那个构造函数是为了容易构造类型相同但分配器不等价的容器。通常,那是不好的,非常不好。要知道为什么,转向条款11。
现在让我们对付更富于挑战性的诊断信息。假定你正在实现一个允许用户通过昵称而不是电子邮件地址查找人的电子邮件程序。例如,这样的程序将可能使用“The Big Cheese”作为美国总统(碰巧是president@whitehouse.gov)电子邮件地址的同义词。这样的程序可以使用一个从昵称到电子邮件地址的映射,并可能提供一个成员函数showEmailAddress,显示和给定的昵称关联的电子邮件地址:
class NiftyEmailProgram {
private:
typedef map<string, string> NicknameMap;
NicknameMap nicknames; // 从昵称到电子邮件
// 地址的映射
public:
...
void showEmailAddress(const string& nickname) const;
};
在showEmailAddress内部,你需要找到与一个特定的昵称关联的映射入口,所以你可能这么写:
void NiftyEmailProgram::showEmailAddress(const string& nickname) const
{
...
NicknameMap::iterator i = nicknames.find(nickname);
if (i != nicknames. end()) ...
...
}
编译器不喜欢这个,而且有好的原因,但原因不明显。为了帮助你指出它,这是一个STL平台有帮助地发出的:
example.cpp(17): error C2440: 'initializing': cannot convert from 'class
std::_Tree<class std::basic_string<char, struct std::char_traits<char>,class
std::allocator<char> >,struct std::pair<class std::basic_string<char, struct
std::char_traits<char>,class std::allocator<char> > const .class
std::basic_string<char, struct std::char_traits<char>,class std::allocator<char> >
>,struct std::map<class std::basic_string<char, struct
std::char_traits<char>,class std::allocator<char> >.class std::basic_string<char,
struct std::char_traits<char>,class std::allocator<char> >,struct std::less<class
std::basic_string<char,structstd::char_traits<char>, class
std::allocator<char> > >,class std::allocator<class std::basic_string<char, struct,
std::char_traits<char>,class std::allocator<char> > > >::_Kfn, struct
std::less<class std::basic_string<char, struct std::char_traits<char>,class
std::allocator<char> > >,class std::allocator<class std::basic_string<char, struct,
std::char_traits<char>,class std::allocator<char> > > >::const_iterator' to 'class
std::_Tree<class std::basic_string<char, struct std::char_traits<char>,class
std::allocator<char> >,struct std::pair<class std::basic_string<char, struct
std::char_traits<char>,class std::allocator<char> > const .class
std::basic_string<char, struct std::char_traits<char>,class std::allocator<char> >
>,struct std::map<class std::basic_string<char, struct
std::char_traits<char>,class std::allocator<char> >,class std::basic_string<char,
struct std::char_traits<char>,class std::allocator<char> >,struct std::less<class
std::basic_string<char,structstd::char_traits<char> .class
std::allocator<char> > >,class std::allocator<class std::basic_string<char,struct
std::char_traits<char>,class std::allocator<char> > > >::_Kfn, struct
std::less<class std::basic_string<char, struct std::char_traits<char>,class
std::allocator<char> > >,class std::allocator<class std::basic_string<char, struct
std::char_traits<char>,class std::allocator<char> > > >::iterator'
No constructor could take the source type, or constructor overload resolution was ambiguous
有2095个字符长,这条消息看起来相当可怕,但我看过更糟的。对于这个例子我最喜欢的STL平台之一产生了一个4812个字节的诊断信息。正如你所猜测的,错误信息以外的特性是造成我喜爱它的原因。
让我们把这团乱麻减少成容易处理的东西。我们从把basic_string乱语替换成string开始。可以产生这个:
example.cpp(17): error C2440: 'initializing': cannot convert from 'class std::_Tree<class string, struct std::pair<class string const,class string >,struct
std::map<class string, class string, struct std::less<class string >,class
std::allocator<class string > >::_Kfn, struct std::less<class string >,class
std::allocator<class string > >::const_iterator' to 'class std::_Tree<class string,
struct std::pair<class string const .class string >,struct std::map<class string,
class string, struct std::less<class string >,class std::allocator<class string>
>::_Kfn,struct std::less<class string >,class std::allocator<class string>
>::iterator'
No constructor could take the source type, or constructor overload resolution was ambiguous
好多了。现在瘦身到745个字符了,我们可以真正地开始看消息了。很可能引起我们注意的东西之一是模板std::_Tree。标准没有说过一个叫_Tree的模板,但名字中的前导下划线随后有一个大写字母唤起了我们的记忆——这样的名字是为实现而保留。这是用来实现STL一些部分的一个内部模板。
实际上,几乎所有STL实现都使用某种内在的模板来实现标准关联容器(set、multiset、map和multimap)。就像使用string的源代码通常导致诊断信息提及basic_string一样,使用标准关联容器的源代码经常会导致诊断信息提及一些内在的树模板。在这里,它叫做_Tree,但我知道的其他实现使用tree或rb_tree,后者反映出使用红-黑树——在STL实现中最常使用的平衡树类型。(译注:红黑树的知识可以在数据结构或算法的相关书籍里找到。)
把_Tree先放到一边,上面的消息提到了一个我们得认出的类型:std::map<class string, class string, struct std::less<class string>, class std::allocator<class string > >。这正好是我们正使用的map类型,除了显示了比较和分配器类型(我们定义map时没有指定它们)。如果我们用那个类型的typedef——NicknameMap——替换了它,错误信息将更容易明白。于是产生了这个:
example.cpp(17): error C2440: 'initializing': cannot convert from 'class
std::_Tree<class string, struct std::pair<class string const, class string >,struct
NicknameMap::_Kfn, struct std::less<class string >,class std::allocator<class
string > >::const_iterator' to 'class std::_Tree<class string, struct std::pair<class
string const ,class string >,struct NicknameMap::_Kfn, struct std::less<class
string >,class std::allocator<class string > >::iterator'
No constructor could take the source type, or constructor overload resolution was ambiguous
这条信息更短,但清楚得多。我们需要对_Tree做一些事情。因为_Tree是一个实现特定(implementation-specific)的模板,知道它的模板参数的意思的唯一的方法是去读源代码,而如果不必,没有理由要去翻寻实现特定的源代码。让我们试着只是用SOMETHING替换传给的_Tree的全部东西来看看我们得到什么。这是结果:
example.cpp(17): error C2440: 'initializing': cannot convert from 'class
std::_Tree<SOMETHING>::const_iterator to 'class
std::_Tree<SOMETHING>::iterator'
No constructor could take the source type, or constructor overload resolution was ambiguous
这是我们能够处理的东西。编译器抱怨我们试图把某种const_iterator转换成iterator,一次对常数正确性的明显破坏。让我们再次看看那段讨厌的代码,我已经高亮了引起编译器怒火的那行:
class NiftyEmailProgram {
private:
typedef map<string, string> NicknameMap;
NicknameMap nicknames;
public:
...
void showEmailAddress(const string& nickname) const;
};
void NiftyEmailProgram::showEmailAddress(const string& nickname) const
{
...
NicknameMap::iterator i = nicknames.find(nickname);
if (i != nicknames.end())...
...
}
有意义的唯一解释是我们试图用一个从map::find返回的const_iterator初始化i(是iterator)。那好像很古怪,因为我们是在nicknames上调用find,而nicknames在非常量对象,find因此应该返回非常量iterator。
再看看。是的,nicknames被声明为一个非常量map,但showEmailAddress是一个const成员函数,而在一个const成员函数内部,类的所有非静态数据成员都变成常量!在showEmailAddress内部,nicknames是一个常量map。突然错误信息有意义了。我们试图产生一个进入我们许诺不要修改的map中的iterator。要解决这个问题,我们必须把i改为const_iterator或我们必须使showEmailAddress成为一个非const成员函数。这两个解决方案的挑战性或许比发现错误信息的意思更少。
在本条款中,我演示了用原文替换降低错误信息的复杂度,但一旦你稍微实践,多数时间里你将可以在头脑中进行替换。我不是音乐家(我连开收音机都有困难),但别人告诉我好的音乐家可以在一瞥之间视读几个小节;他们不需要看独立的音符。有经验的STL程序员发展出一项类似的技能。他们可以不假思索地在内部把比如std::basic_string<char, struct std::char_traits<char>, class std::allocator<char> >翻译为string。你也要发展这项技能,但在你能做到之前,记得你总是可以通过用更短的记忆术替换冗长的基于模板的类型名字来把编译器诊断信息降低到可以理解的东西。在许多场合,你要做的就是用你已经使用的typedef名字替换typedef展开。那是我们用NicknameMap替换std::map<class string, class string, struct std::less<class string >, class::allocator<class string > >时所做的。
这里有一些应该能帮助你理解有关STL的编译器消息的其它提示:
对于vector和string,迭代器有时是指针,所以如果你用迭代器犯了错误,编译器诊断信息可能会提及涉及指针类型。例如,如果你的源代码涉及vector<double>::iterator,编译器消息有时会提及double*指针。(一个值得注意的例外是当你使用来自STLport的STL实现,而且你运行在调试模式。那样的话,vector和string的迭代器干脆不是指针。对STLport和它调试模式的更多信息,转向条款50。)
提到back_insert_iterator、front_insert_iterator或insert_iterator的消息经常意味着你错误调用了back_inserter、front_inserter或inserter,一一对应,(back_inserter返回back_insert_iterator类型的对象,front_inserter返回front_insert_iterator类型的对象,而inserter返回insert_iterator类型的对象。关于使用这些inserter的信息,参考条款30。)如果你没有调用这些函数,你(直接或间接)调用的一些函数做了。
类似地,如果你得到的一条消息提及binder1st或binder2nd,你或许错误地使用了bind1st或bind2nd。(bind1st返回binder1st类型的对象,而bind2nd返回binder2nd类型的对象。)
输出迭代器(例如ostream_iterator、ostreambuf_iterators(参见条款29),和从back_inserter、front_inserter和inserter返回的迭代器)在赋值操作符内部做输出或插入工作,所以如果你错误使用了这些迭代器类型之一,你很可能得到一条消息,抱怨在你从未听说过的一个赋值操作符里的某个东西。为了明白我的意思,试着编译这段代码:
vector<string*> v; // 试图打印一个
copy(v.begin(), v.end(), // string*指针的容器,
ostream_iterator<string>(cout, "\n")); // 被当作string对象
你得到一条源于STL算法实现内部的错误信息(即,源代码引发的错误在<algorithm>中),也许是你试图给那算法用的类型出错了。例如,你可能传了错误种类的迭代器。要看看这样的用法错误是怎样报告的,通过把这段代码喂给你的编译器来启发(并愉快!)自己:
```
list<int>::iterator i1, i2; // 把双向迭代器
sort(i1, i2); // 传给一个需要
// 随机访问迭代器的算法
```
你使用常见的STL组件比如vector、string或for_each算法,而编译器说不知道你在说什么,你也许没有#include一个需要的头文件。正如条款48的解释,这问题会降临在长期以来都可以顺利编译而刚移植到新平台的代码。
STL编程的次要麻烦之一是虽然可以很容易地建立可以在一个平台上编译的软件,但在其它平台上则需要附加的#include指示。这个烦恼来自一个事实:C++标准(不像C标准)未能指定哪一个标准头文件必须或者可能被其他标准头文件#include。由于有了这样的灵活性,不同的实现就会选择去做不同的东西。
这在实践中意味着什么?我可以给你一些的概念。我使用了五个STL平台(咱们叫它们A、B、C、D和E),花了一些时间在它们上测试了一些小程序来看看我可以在忽略哪个标准头文件的情况下仍然成功编译。这间接地告诉我哪个头文件#include了其他的。这是我所发现的:
- 对于A和C,<vector> #includes <string>.
- 对于C,<algorithm> #includes <string>.
- 对于C和D, <iostream> #includes <iterator>.
- 对于D, <iostream> #includes <string> and <vector>.
- 对于D和E, <string> #includes <algorithm>.
- 对于所有的五个实现,<set> #includes <functional>.
除了<set> #include <functional>外,我无法使缺少头文件的程序通过实现B。按照Murphy定律,你总是会在像A、C、D或E那样的平台上开发,然后移植到像B那样的平台,尤其是当移植的压力很大而且完成的时间很紧的情况下。
但是请别指责你将要移植的编译器或库实现。如果你缺少了需要的头文件,这就是你的过错。无论何时你引用了std名字空间里的元素,你就应该对#include合适的头文件负责。如果你遗漏了它们,你的代码也可能编译,但你仍然缺少了必要的头文件,而其他STL平台可能正好会抵制你的代码。
要帮你记起需要的东西,关于在每个标准STL相关的头文件中都有什么,这里有一个快速概要:
- 几乎所有的容器都在同名的头文件里,比如,vector在<vector>中声明,list在<list>中声明等。例外的是<set>和<map>。<set>声明了set和multiset,<map>声明了map和multimap。
- 除了四个算法外,所有的算法都在<algorithm>中声明。例外的是accumulate(参见条款37)、inner_product、adjacent_difference和partial_sum。这些算法在<numeric>中声明。
- 特殊的迭代器,包括istream_iterators和istreambuf_iterators(参见条款29),在<iterator>中声明。
- 标准仿函数(比如less<T>)和仿函数适配器(比如not1、bind2nd)在<functional>中声明。
无论何时你使用了一个头文件中的任意组件,就要确定提供了相应的#include指示,就算你的开发平台允许你不用它也能通过编译。当你发现移植到一个不同的平台时这么做可以减少压力,你的勤奋将因而得到回报。
假设你有一个vector<int>
,你想去掉vector中值小于x而出现在至少和y一样大的最后一个元素之后的所有元素。下面代码立刻出现在你脑中吗?
vector<int> v;
int x, y;
...
v.erase(
remove_if(find_if(v.rbegin(), v.rend(),
bind2nd(greater_equal<int>(), y)).base(),
v.end(),
bind2nd(less<int>(), x)),
v.end());
一条语句就完成了工作。清楚并直接了当。没问题。对吗?
好,让我们先退回一步。对你来说它是合理的、好维护的代码?“不!”大部分C++程序员惊叫,他们的声音中充满了害怕和讨厌。“是!”他们中的一部分显然高兴地说。但那儿有一个问题。一个程序员很有表现力的方法是另一个程序员的噩梦。
正如我所见,有上面代码涉及到两个问题。首先,它是函数调用的鼠巢。要知道我是什么意思,这里有一个相同的语句,但所有的函数名都替换为fn,每个n对应一个函数:
v.f1(f2(f3(v.f4(), v.f5(), f6(f7(), y)),.f8(), v.f9(), f6(f10(), x)), v.f9());
这看起来不自然地复杂,因为我去掉了出现在原例中的缩进,但我想它可以很明白的表示了如果一条语句使用了12次函数调用,分别调用了10个不同的函数,那会被大部分C++程序开发人员认为很过分。但曾经用过比如Scheme的函数式语言(functional language)的程序员可能感觉不同,而我的经验是大多数看到原来的代码而没有感到惊讶的程序员有一个很强的函数式语言背景。大部分C++程序员缺乏这样的背景,所以除非你的同事精通深度嵌套(nest)的函数调用的方法,类似上面erase调用的代码几乎可以肯定会打败下一个想要弄明白你写的代码的人。
这段代码的第二个缺点是需要很多STL背景才能明白它。它使用了不常见的_if形式的find和remove,它使用了逆向迭代器(参见条款26),它把reverse_iterator转换为iterator(参见条款28),它使用了bind2nd,它建立了匿名函数对象而且它使用了erase-remove惯用法(参见条款32)。有经验的STL程序员可以轻易地吞下那些组合,但更多C++开发者在一口吃掉之前会一遍一遍看。如果你的同事对在一条语句中使用erase、remove_if、find_if、base和bind2ndSTL的这种方法都很熟悉,那可能很好,但如果你想让你的代码易于被主流背景更多的C++程序员了解,我鼓励你把它分解为易于消化的块。
这是一种你可以使用的方法。(注释不光为了本书。我也会把它们放入代码。)
typedef vector<int>::iterator VecIntIter;
// 把rangeBegin初始化为指向最后一个
// 出现一个大于等于y的值后面的元素。
// 如果没有那样的值,
// 把rangeBegin初始化为v.begin()。如果那个值的
// 最后一次出现是v中的最后一个元素,
// 就把rangeBegin初始化为v.end()。
VecIntIter rangeBegin = find_if(v.rbegin(), v.rend(),
bind2nd(greater_equal<int>(), y)).base();
// 从rangeBegin到v.end(),删除所有小于x的值
v.erase(remove_if(rangeBegin, v.end(), bind2nd(less<int>(), x)), v.end());
这仍然可能把一些人弄糊涂,因为它依赖于对erase-remove惯用法的了解,但在代码的注释和一本好的STL参考(比如,Josuttis的《The C++ Standard Library》[3]或SGI的STL网站[21])之间,每个C++程序员应该可以不太困难地指出它作了什么。
当转换代码时,要注意我并没有放弃算法并试图自己写的循环。条款43解释了为什么那一般是一个劣等的选择,它的论点在这里。当写源代码时,目标是写出对编译器和人都有意义的代码,并提供可接受的性能。算法基本上总是最好地达到那个目标的方式。但是,条款43也解释了增加算法的时候会自然导致增加嵌套函数调用并大量使用绑定器和其他仿函数适配器。再看看本条款开头的问题描述:
假设你有一个vector<int>
,你想去掉vector中值小于x而出现在至少和y一样大的最后一个元素之后的所有元素。
一个方案的轮廓跳入脑中:
找到vector中一个值的最后一次出现需要用逆向迭代器调用find或find_if的某种形式。
去掉元素需要调用erase或erase-remove惯用法。
把这两个想法加在一起,你可以得到这个伪代码,其中“something”表示一个用于还没有填充的代码的占位符:
v.erase(remove_if(find_if(v.rbegin(), v.rend(), something).base(),
v.end(),
something)),
v.end());
一旦你完成了这个,确定something就不是很难了,下一个事情你是知道的,你有原例中的代码。那是为什么这种语句一般被称为“只写(write-only)”代码。当你写代码时,它似乎很直截了当,因为它是一些基本想法(也就是,erase-remove惯用法加上使用逆向迭代器调用find的想法)的自然产物。但是,读者们很难把最后的产物分解回它基于的想法。这就被称为只写代码:很容易写,但很难读和理解。
代码是否是只写依赖于读它的人。正如我提到的,有些C++程序员不反感本条款中的代码。如果这是你工作环境的典型而且你希望它在未来也典型,那就自由释放出你最多的高级STL编程爱好。但是,如果你的同时不适应函数式编程风格而且STL经验很少,收回你的野心,写一些接近我前面演示的两条语句的方法的东西。
代码的读比写更经常,这是软件工程的真理。也就是说软件的维护比开发花费多得多的时间。不能读和理解的软件不能被维护,不能维护的软件几乎没有不值得拥有。你用STL越多,你会感到它越来越舒适,而且你会越来越多的使用嵌套函数调用和即时(on the fly)建立函数对象。这没有什么错的,但永远记住你今天写的代码会被某个人——也可能是你——在未来的某一天读到。为那天做准备吧。
当然,使用STL,好好使用,有效使用。但避免产生只写代码。最后,这样的代码完全不高效。
一个关于用高级语言编程的抱怨是抽象层次越高,产生的代码效率就越低。事实上,Alexander Stepanov(STL的发明者)有一次作了一组小测试,试图测量出相对C,C++的“抽象惩罚”。其中,测试结果显示基本上操作包含一个double的类产生的代码效率比对应的直接操作一个double的代码低。因此你可能会奇怪地发现把STL函数对象——化装成函数的对象——传递给算法所产生的代码一般比传递真的函数高效。
比如,假设你需要以降序排序一个double的vector。最直接的STL方式是通过sort算法和greater<double>
类型的函数对象:
vector<double> v;
...
sort(v.begin(), v.end(), greater<double>());
如果你注意了抽象惩罚,你可能决定避开函数对象而使用真的函数,一个不光是真的,而且是内联的函数:
inline
bool doubleGreater(double d1, double d2)
{
return dl > d2;
}
...
sort(v.begin(), v.end(), doubleGreater);
有趣的是,如果你比较了两个sort调用(一个使用greater<double>
,一个使用doubleGreater)的性能,你基本上可以明确地知道使用greater<double>
的那个更快。实际来说,我在四个不同的STL平台上测量了对一百万个double的vector的两个sort调用,每个都设为针对速度优化,使用greater<double>
的版本每次都比较快。最差的情况下,快50%,最好的快了160%。抽象惩罚真多啊。
这个行为的解释很简单:内联。如果一个函数对象的operator()函数被声明为内联(不管显式地通过inline或者隐式地通过定义在它的类定义中),编译器就可以获得那个函数的函数体,而且大部分编译器喜欢在调用算法的模板实例化时内联那个函数。在上面的例子中,greater<double>::operator()
是一个内联函数,所以编译器在实例化sort时内联展开它。结果,sort没有包含一次函数调用,而且编译器可以对这个没有调用操作的代码进行其他情况下不经常进行的优化。(内联和编译器优化之间交互的讨论,参见《Effective C++》的条款33和Bulka与Mayhew的《Efficient C++》[10]的第8-10章。)
使用doubleGreater调用sort的情况则不同。要知道有什么不同,我们必须知道不可能把一个函数作为参数传给另一个函数。当我们试图把一个函数作为参数时,编译器默默地把函数转化为一个指向那个函数的指针,而那个指针是我们真正传递的。因此,这个调用
sort(v.begin(), v.end(), doubleGreater);
不是真的把doubleGreater传给sort,它传了一个doubleGreater的指针。当sort模板实例化时,这是产生函数的声明:
void sort(vector<double>::iterator first, // 区间起点
vector<double>::iterator last, // 区间终点
bool (*comp)(double, double)); // 比较函数
因为comp是一个指向函数的指针,每次在sort中用到时,编译器产生一个间接函数调用——通过指针调用。大部分编译器不会试图去内联通过函数指针调用的函数,甚至,正如本例中,那个函数已经声明为inline而且这个优化看起来很直接。为什么不?可能因为编译器厂商从来没有觉得值得实现这个优化。你得稍微同情一下编译器厂商。他们有很多需求,而他们不能做每一件事。你的需要并不能让他们实现那个优化。
把函数指针作为参数会抑制内联的事实解释了一个长期使用C的程序员经常发现却难以相信的现象:在速度上,C++的sort实际上总是使C的qsort感到窘迫。当然,C++有函数、实例化的类模板和看起来很有趣的operator()函数需要调用,而C只是进行简单的函数调用,但所有的C++“开销”都在编译期被吸收。在运行期,sort内联调用它的比较函数(假设比较函数已经被声明为inline而且它的函数体在编译期可以得到)而qsort通过一个指针调用它的比较函数。结果是sort运行得更快。在我的测试的一百万个double的vector上,它快670%,但不要光相信我的话,亲自试试。很容易证实当比较函数对象和真的函数作为算法的参数时,那是一个纯红利。
还有另一个使用函数对象代替函数作为算法参数的理由,而它与效率无关。它涉及到让你的程序可以编译。对于任何理由,STL平台经常完全拒绝有效代码,即使编译器或库或两者都没问题。比如,一个广泛使用的STL平台拒绝了下面(有效的)代码来从cout打印出set中每个字符串的长度:
set<string> s;
...
transform(s.begin(), s.end(),
ostream_iterator<string::size_type>(cout, "\n"),
mem_fun_ref(&string::size));
这个问题的原因是这个特定的STL平台在处理const成员函数时有bug(比如string::size)。一个变通方法是改用函数对象:
struct StringSize:
public unary_function<string, string::size_type>{ // 参见条款40
string::size_type operator()(const string& s) const
{
return s.size();
}
};
transform(s.begin(), s.end(),
ostream_iterator<string::size_type>(cout, "\n"),
StringSize());
解决这问题的还有其他变通办法,但这个不仅在我知道的每个STL平台都可以编译。而且它简化了string::size的内联调用,那是几乎不会在上面把mem_fun_ref(&string::size)传给transform的代码中发生的事情。换句话说,仿函数类StringSize的创造不仅避开了编译器一致性问题,而且可能会带来性能提升。
另一个用函数对象代替函数的原因是它们可以帮助你避免细微的语言陷阱。有时候,看起来合理代码被编译器由于合法性的原因——但很模糊——而拒绝。有很多这样的情况,比如,当一个函数模板实例化的名字不等价于函数名。这是一种这样的情况:
template<typename FPType> // 返回两个
FPTypeaverage(FPType val1, FPType val2) // 浮点数的平均值
{
return (val1 + val2) / 2;
}
template<typename InputIter1,
typename InputIter2>
void writeAverages(InputIter1 begin1, // 把两个序列的
InputIter1 end1, // 成对的平均值
InputIter2 begin2, // 写入一个流
ostream& s)
{
transform(
begin1, end1, begin2,
ostream_iterator<typename iterator_traits
<lnputIter1>::value_type> (s, "\n"),
average<typename iteraror_traits
<InputIter1>::value_type> // 有错?
);
}
很多编译器接受这段代码,但是C++标准倾向于禁止它。理由是理论上有可能存在另一带有一个类型参数的函数模板叫做average。如果有,表达式average<typename iterator_traits<lnputIter1>::value_type>
将是模棱两可的,因为它不清楚将实例化哪个模板。在这个特殊的例子里,不存在模棱两可,但是无论如何,有些编译器会拒绝这段代码,而且那么做是允许的。没有关系。解决这个问题的方法是依靠一个函数对象:
template<typename FPType>
struct Average:
public binary_function<FPType, FPType, FPType>{ // 参见条款40
FPType operator()(FPType val1. FPType val2) const
{
return average(val 1 , val2);
}
template<typename InputIter1, typename InputIter2>
void writeAverages(lnputIter1 begin1, InputIter1 end1,
InputIter2 begin2, ostream& s)
{
transform(
begin1, end1, begin2,
ostream_iterator<typename iterator_traits<lnputIter1>
::value_type>(s, "\n"),
Average<typename iterator_traits<InputIter1>
::value_type>()
);
}
每个编译器都会接受这条修正的代码。而且在transform里调用Average::operator()符合内联的条件,有些事情对于实例化上面的average不成立,因为averate是一个函数模板,不是函数对象。
把函数对象作为算法的参数所带来的不仅是巨大的效率提升。在让你的代码可以编译方面,它们也更稳健。当然,真函数很有用,但是当涉及有效的STL编程时,函数对象经常更有用。
你要寻找什么,而且你有一个容器或者你有一个由迭代器划分出来的区间——你要找的东西就在里面。你要怎么完成搜索呢?你箭袋中的箭有这些:count、count_if、find、find_if、binary_search、lower_bound、upper_bound和equal_range。面对着它们,你要怎么做出选择?
简单。你寻找的是能又快又简单的东西。越快越简单的越好。
暂时,我假设你有一对指定了搜索区间的迭代器。然后,我会考虑到你有的是一个容器而不是一个区间的情况。
要选择搜索策略,必须依赖于你的迭代器是否定义了一个有序区间。如果是,你就可以通过binary_search、lower_bound、upper_bound和equal_range来加速(通常是对数时间——参见条款34)搜索。如果迭代器并没有划分一个有序区间,你就只能用线性时间的算法count、count_if、find和find_if。在下文中,我会忽略掉count和find是否有_if的不同,就像我会忽略掉binary_search、lower_bound、upper_bound和equal_range是否带有判断式的不同。你是依赖默认的搜索谓词还是指定一个自己的,对选择搜索算法的考虑是一样的。
如果你有一个无序区间,你的选择是count或着find。它们分别可以回答略微不同的问题,所以值得仔细去区分它们。count回答的问题是:“是否存在这个值,如果有,那么存在几份拷贝?”而find回答的问题是:“是否存在,如果有,那么它在哪儿?”
假设你想知道的东西是,是否有一个特定的Widget值w在list中。如果用count,代码看起来像这样:
list<Widget> lw; // Widget的list
Widget w; // 特定的Widget值
...
if (count(lw.begin(), lw.end(), w)) {
... // w在lw中
} else {
... // 不在
}
这里示范了一种惯用法:把count用来作为是否存在的检查。count返回零或者一个正数,所以我们把非零转化为true而把零转化为false。如果这样能使我们要做的更加显而易见:
if (count(lw.begin(), lw.end(), w) != 0) ...
而且有些程序员这样写,但是使用隐式转换则更常见,就像最初的例子。
和最初的代码比较,使用find略微更难懂些,因为你必须检查find的返回值和list的end迭代器是否相等:
if (find(lw.begin(), lw.end(), w) != lw.end()) {
... // 找到了
} else {
... // 没找到
}
如果是为了检查是否存在,count这个惯用法编码起来比较简单。但是,当搜索成功时,它的效率比较低,因为当找到匹配的值后find就停止了,而count必须继续搜索,直到区间的结尾以寻找其他匹配的值。对大多数程序员来说,find在效率上的优势足以证明略微增加复杂度是合适的。
通常,只知道区间内是否有某个值是不够的。取而代之的是,你想获得区间中的第一个等于该值的对象。比如,你可能想打印出这个对象,你可能想在它前面插入什么,或者你可能想要删除它(但当迭代时删除的引导参见条款9)。当你需要知道的不止是某个值是否存在,而且要知道哪个对象(或哪些对象)拥有该值,你就得用find:
list<Widget>::iterator i = find(lw.begin(), lw.end(), w);
if (i != lw.end()) {
... // 找到了,i指向第一个
} else {
... // 没有找到
}
对于有序区间,你有其他的选择,而且你应该明确的使用它们。count和find是线性时间的,但有序区间的搜索算法(binary_search、lower_bound、upper_bound和equal_range)是对数时间的。
从无序区间迁移到有序区间导致了另一个迁移:从使用相等来判断两个值是否相同到使用等价来判断。条款19由一个详细地讲述了相等和等价的区别,所以我在这里不会重复。取而代之的是,我会简单地说明count和find算法都用相等来搜索,而binary_search、lower_bound、upper_bound和equal_range则用等价。
要测试在有序区间中是否存在一个值,使用binary_search。不像标准C库中的(因此也是标准C++库中的)bsearch,binary_search只返回一个bool:这个值是否找到了。binary_search回答这个问题:“它在吗?”它的回答只能是是或者否。如果你需要比这样更多的信息,你需要一个不同的算法。
这里有一个binary_search应用于有序vector的例子(你可以从条款23中知道有序vector的优点):
vector<Widget> vw; // 建立vector,放入
... // 数据,
sort(vw.begin(), vw.end()); // 把数据排序
Widget w; // 要找的值
...
if (binary_search(vw.begin(), vw.end(), w)) {
... // w在vw中
} else {
... // 不在
}
如果你有一个有序区间而且你的问题是:“它在吗,如果是,那么在哪儿?”你就需要equal_range,但你可能想要用lower_bound。我会很快讨论equal_range,但首先,让我们看看怎么用lower_bound来在区间中定位某个值。
当你用lower_bound来寻找一个值的时候,它返回一个迭代器,这个迭代器指向这个值的第一个拷贝(如果找到的话)或者到可以插入这个值的位置(如果没找到)。因此lower_bound回答这个问题:“它在吗?如果是,第一个拷贝在哪里?如果不是,它将在哪里?”和find一样,你必须测试lower_bound的结果,来看看它是否指向你要寻找的值。但又不像find,你不能只是检测lower_bound的返回值是否等于end迭代器。取而代之的是,你必须检测lower_bound所标示出的对象是不是你需要的值。
很多程序员这么用lower_bound:
vector<Widget>::iterator i = lower_bound(vw.begin(), vw.end(), w);
if (i != vw.end() && *i == w) { // 保证i指向一个对象;
// 也就保证了这个对象有正确的值。
// 这是个bug!
... // 找到这个值,i指向
// 第一个等于该值的对象
} else {
... // 没找到
}
大部分情况下这是行得通的,但不是真的完全正确。再看一遍检测需要的值是否找到的代码:
if (i != vw.end() && *i == w) ...
这是一个相等的测试,但lower_bound搜索用的是等价。大部分情况下,等价测试和相等测试产生的结果相同,但就像条款19论证的,相等和等价的结果不同的情况并不难见到。在这种情况下,上面的代码就是错的。
要完全完成,你就必须检测lower_bound返回的迭代器指向的对象的值是否和你要寻找的值等价。你可以手动完成(条款19演示了你该怎么做,当它值得一做时条款24提供了一个例子),但可以更狡猾地完成,因为你必须确认使用了和lower_bound使用的相同的比较函数。一般而言,那可以是一个任意的函数(或函数对象)。如果你传递一个比较函数给lower_bound,你必须确认和你的手写的等价检测代码使用了相同的比较函数。这意味着如果你改变了你传递给lower_bound的比较函数,你也得对你的等价检测部分作出修改。保持比较函数同步不是火箭发射,但却是另一个要记住的东西,而且我想你已经有很多需要你记的东西了。
这儿有一个简单的方法:使用equal_range。equal_range返回一对迭代器,第一个等于lower_bound返回的迭代器,第二个等于upper_bound返回的(也就是,等价于要搜索值区间的末迭代器的下一个)。因此,equal_range,返回了一对划分出了和你要搜索的值等价的区间的迭代器。一个名字很好的算法,不是吗?(当然,也许叫equivalent_range会更好,但叫equal_range也非常好。)
对于equal_range的返回值,有两个重要的地方。第一,如果这两个迭代器相同,就意味着对象的区间是空的;这个只没有找到。这个结果是用equal_range来回答“它在吗?”这个问题的答案。你可以这么用:
vector<Widget> vw;
...
sort(vw.begin(), vw.end());
typedef vector<Widget>::iterator VWIter; // 方便的typedef
typedef pair<VWIter, VWIter> VWIterPair;
VWIterPair p = equal_range(vw.begin(), vw.end(), w);
if (p.first != p.second) { // 如果equal_range不返回
// 空的区间...
... // 说明找到了,p.first指向
// 第一个而p.second
// 指向最后一个的下一个
} else {
... // 没找到,p.first和
// p.second都指向搜索值
} // 的插入位置
这段代码只用等价,所以总是正确的。
第二个要注意的是equal_range返回的东西是两个迭代器,对它们作distance就等于区间中对象的数目,也就是,等价于要寻找的值的对象。结果,equal_range不光完成了搜索有序区间的任务,而且完成了计数。比如说,要在vw中找到等价于w的Widget,然后打印出来有多少这样的Widget存在,你可以这么做:
VWIterPair p = equal_range(vw.begin(), vw.end(), w);
cout << "There are " << distance(p.first, p.second)
<< " elements in vw equivalent to w.";
到目前为止,我们所讨论的都是假设我们要在一个区间内搜索一个值,但是有时候我们更感兴趣于在区间中寻找一个位置。比如,假设我们有一个Timestamp类和一个Timestamp的vector,它按照老的timestamp放在前面的方法排序:
class Timestamp { ... };
bool operator<(const Timestamp& lhs, // 返回在时间上lhs
const Timestamp& rhs); // 是否在rhs前面
vector<Timestamp> vt; // 建立vector,填充数据,
... // 排序,使老的时间
sort(vt.begin(), vt.end()); // 在新的前面
现在假设我们有一个特殊的timestamp——ageLimit,而且我们从vt中删除所有比ageLimit老的timestamp。在这种情况下,我们不需要在vt中搜索和ageLimit等价的Timestamp,因为可能不存在任何等价于这个精确值的元素。 取而代之的是,我们需要在vt中找到一个位置:第一个不比ageLimit更老的元素。这是再简单不过的了,因为lower_bound会给我们答案的:
Timestamp ageLimit;
...
vt.erase(vt.begin(), lower_bound(vt.begin(), // 从vt中排除所有
vt.end(), // 排在ageLimit的值
ageLimit)); // 前面的对象
如果我们的需求稍微改变了一点,我们要排除所有至少和ageLimit一样老的timestamp,也就是我们需要找到第一个比ageLimit年轻的timestamp的位置。这是一个为upper_bound特制的任务:
vt.erase(vt.begin(), upper_bound(vt.begin(), // 从vt中除去所有
vt.end(), // 排在ageLimit的值前面
ageLimit)); // 或者等价的对象
如果你要把东西插入一个有序区间,而且对象的插入位置是在有序的等价关系下它应该在的地方时,upper_bound也很有用。比如,你可能有一个有序的Person对象的list,对象按照name排序:
class Person {
public:
...
const string& name() const;
...
};
struct PersonNameLess:
public binary_function<Person, Person, bool> { // 参见条款40
bool operator()(const Person& lhs, const Person& rhs) const
{
return lhs.name() < rhs.name();
}
};
list<Person> lp;
...
lp.sort(PersonNameLess()); // 使用PersonNameLess排序lp
要保持list仍然是我们希望的顺序(按照name,插入后等价的名字仍然按顺序排列),我们可以用upper_bound来指定插入位置:
Person newPerson;
...
lp.insert(upper_bound(lp.begin(), // 在lp中排在newPerson
lp.end(), // 之前或者等价
newPerson, // 的最后一个
PersonNameLess()), // 对象后面
newPerson); // 插入newPerson
这工作的很好而且很方便,但很重要的是不要被误导——错误地认为upper_bound的这种用法让我们魔术般地在一个list里在对数时间内找到了插入位置。我们并没有——条款34解释了因为我们用了list,查找花费线性时间,但是它只用了对数次的比较。
一直到这里,我都只考虑我们有一对定义了搜索区间的迭代器的情况。通常我们有一个容器,而不是一个区间。在这种情况下,我们必须区别序列和关联容器。对于标准的序列容器(vector、string、deque和list),你应该遵循我在本条款提出的建议,使用容器的begin和end迭代器来划分出区间。
这种情况对标准关联容器(set、multiset、map和multimap)来说是不同的,因为它们提供了搜索的成员函数,它们往往是比用STL算法更好的选择。条款44详细说明了为什么它们是更好的选择,简要地说,是因为它们更快行为更自然。幸运的是,成员函数通常和相应的算法有同样的名字,所以前面的讨论推荐你使用的算法count、find、equal_range、lower_bound或upper_bound,在搜索关联容器时你都可以简单的用同名的成员函数来代替。
调用binary_search的策略不同,因为这个算法没有提供对应的成员函数。要测试在set或map中是否存在某个值,使用count的惯用方法来对成员进行检测:
set<Widget> s; // 建立set,放入数据
...
Widget w; // w仍然是保存要搜索的值
...
if (s.count(w)) {
... // 存在和w等价的值
} else {
... // 不存在这样的值
}
要测试某个值在multiset或multimap中是否存在,find往往比count好,因为一旦找到等于期望值的单个对象,find就可以停下了,而count,在最遭的情况下,必须检测容器里的每一个对象。(对于set和map,这不是问题,因为set不允许重复的值,而map不允许重复的键。)
但是,count给关联容器计数是可靠的。特别,它比调用equal_range然后应用distance到结果迭代器更好。首先,它更清晰:count 意味着“计数”。第二,它更简单;不用建立一对迭代器然后把它的组成(译注:就是first和second)传给distance。第三,它可能更快一点。
要给出所有我们在本条款中所考虑到的,我们的从哪儿着手?下面的表格道出了一切。
你想知道的 使用的算法 使用的成员函数
在无序区间 在有序区间 在set或map上 在multiset或multimap上
期望值是否存在? find binary_search count find
期望值是否存在?如果有,第一个等于这个值的对象在哪里? find equal_range find find或lower_bound(参见下面)
第一个不在期望值之前的对象在哪里? find_if lower_bound lower_bound lower_bound
第一个在期望值之后的对象在哪里? find_if upper_bound upper_bound upper_bound
有多少对象等于期望值? count equal_range,然后distance count count
等于期望值的所有对象在哪里? find(迭代) equal_range equal_range equal_range
上表总结了要怎么操作有序区间,equal_range的出现频率可能令人吃惊。当搜索时,这个频率因为等价检测的重要性而上升了。对于lower_bound和upper_bound,它很容易在相等检测中退却,但对于equal_range,只检测等价是很自然的。在第二行有序区间,equal_range打败了find还因为一个理由:equal_range花费对数时间,而find花费线性时间。
对于multiset和multimap,当你在搜索第一个等于特定值的对象的那一行,这个表列出了find和lower_bound两个算法作为候选。 已对于这个任务find是通常的选择,而且你可能已经注意到在set和map那一列里,这项只有find。但是对于multi容器,如果不只有一个值存在,find并不保证能识别出容器里的等于给定值的第一个元素;它只识别这些元素中的一个。如果你真的需要找到等于给定值的第一个元素,你应该使用lower_bound,而且你必须手动的对第二部分做等价检测,条款19的内容可以帮你确认你已经找到了你要找的值。(你可以用equal_range来避免作手动等价检测,但是调用equal_range的花费比调用lower_bound多得多。)
在count、find、binary_search、lower_bound、upper_bound和equal_range中做出选择很简单。当你调用时,选择算法还是成员函数可以给你需要的行为和性能,而且是最少的工作。按照这个建议做(或参考那个表格),你就不会再有困惑。
有些容器拥有和STL算法同名的成员函数。关联容器提供了count、find、lower_bound、upper_bound和equal_range,而list提供了remove、remove_if、unique、sort、merge和reverse。大多数情况下,你应该用成员函数代替算法。这样做有两个理由。首先,成员函数更快。其次,比起算法来,它们与容器结合得更好(尤其是关联容器)。那是因为同名的算法和成员函数通常并不是是一样的。
我们以对关联容器的实验开始。假如有一个set<int>,它容纳了一百万个元素,而你想找到元素727的第一个出现位置(如果存在的话)。这儿有两个最自然的方法来执行搜索:
set<int> s; // 建立set,放入1,000,000个数据
...
set<int>::iterator i = s.find(727); // 使用find成员函数
if (i != s.end()) ...
set<int>::iterator i = find(s.begin(), s.end(), 727); // 使用find算法
if (i != s.end()) ...
find成员函数运行花费对数时间,所以不管727是否存在于此set中,set::find只需执行不超过40次比较来查找它,而一般只需要大约20次。相反,find算法运行花费线性时间,所以如果727不在此set中,它需要执行1,000,000次比较。即使727在此set中,也平均需要执行500,000次比较来找到它。效率得分如下:
find成员:大约40(最坏的情况)至大约20(平均情况)
find算法:1,000,000(最坏的情况)至500,000(平均情况)
和高尔夫比赛一样,分值低的赢。正如你所见,这场比赛结果没什么可说的。
我必须对find成员函数所需的比较次数表示小小的谨慎,因为它有些依赖于关联容器的实现。绝大部分的实现是使用的红黑树——平衡树的一种——失衡度可能达到2。在这样的实现中,对一百万个元素的set进行搜索所需最多的比较次数是38次。但对绝大部分的搜索情况而言,只需要不超过22次。一个基于完全平衡树的实现绝不需要超过21次比较,但在实践中,完全平衡树的效率总的来说不如红黑树。这就是为什么大多数的STL实现都使用红黑树。
效率不是find成员函数和find算法间的唯一差别。正如条款19所解释的,STL算法判断两个对象是否相同的方法是检查的是它们是否相等,而关联容器是用等价来测试它们的“同一性”。 因此,find算法搜索727用的是相等,而find成员函数用的是等价。相等和等价间的区别可能造成成功搜索和不成功搜索的区别。比如说,条款19演示了用find算法在关联容器搜索失败而用find成员函数却搜索成功的情况!因此,如果使用关联容器的话,你应该尽量使用成员函数形式的find、count、lower_bound等等,而不是同名的算法,因为这些成员函数版本提供了和其它成员函数一致的行为。由于相等和等价间的差别,算法不能提供这样的一致行为。
这一差别对map和multimap尤其明显,因为它们容纳的是对象对(pair object),而它们的成员函数只在意对象对的key部分。因此,count成员函数只统计key值匹配的对象对的数目(所谓“匹配”,自然是检测等价情况);对象对的value部份被忽略。成员函数find、lower_bound、upper_bound和equal_range也是如此。但是如果你使用count算法,它的寻找将基于(a)相等和(b)对象对的全部组成部分;算法find、lower_bound等同样如此。要想让算法只关注于对象对的key部分,必须要跳出条款23描述的限制(那儿介绍了用相等测试代替等价测试的方法)。
另一方面,如果你真的关心效率,你可以采用条款23中的技巧,联合条款34中讲的对数时间搜索算法。相对于性能的提升,这只是一个很小的代价。再者,如果你真的在乎效率,你应该考虑非标准的散列容器(在条款25进行了描述),只是,你将再次面对相等和等价的区别。
对于标准的关联容器,选择成员函数而不是同名的算法有几个好处。首先,你得到的是对数时间而不是线性时间的性能。其次,你判断两个元素“相同”使用的是等价,这是关联容器的默认定义。第三,当操纵map和multimap时,你可以自动地只处理key值而不是(key, value)对。这三点给了优先使用成员函数完美的铁甲。
让我们转到list的与算法同名的成员函数身上。这里的故事几乎全部是关于效率的。每个被list作了特化的算法(remove、remove_if、unique、sort、merge和reverse)都要拷贝对象,而list的特别版本什么都没有拷贝;它们只是简单地操纵连接list的节点的指针。算法和成员函数的算法复杂度是相同的,但如果操纵指针比拷贝对象的代价小的话,list的版本应该提供更好的性能。
牢牢记住这一点很重要:list成员函数的行为和它们的算法兄弟的行为经常不相同。正如条款32所解释的,如果你真的想从容器中清除对象的话,调用remove、remove_if和unique算法后,必须紧接着调用erase函数;但list的remove、remove_if和unique成员函数真的去掉了元素,后面不需要接着调用erase。
在sort算法和list的sort成员函数间的一个重要区别是前者不能用于list。作为单纯的双向迭代器,list的迭代器不能传给sort算法。merge算法和list的merge成员函数之间也同样存在巨大差异。这个算法被限制为不能修改源范围,但list::merge总是修改它的宿主list。
所以,你明白了吧。当面临着STL算法和同名的容器成员函数间进行选择时,你应该尽量使用成员函数。几乎可以肯定它更高效,而且它看起来也和容器的惯常行为集成得更好。
每个算法接受至少一对用来指示将被操作的对象区间的迭代器。比如,min_element可以找出此区间中的最小的值,而accumulate则对区间内的元素作某种形式的整体求和运算(参见条款37),partition将区间内的元素分割为满足和不满足某判决条件的两个部分(参见条款31)。当算法被执行时,它们必须检查指示给它的区间中的每个元素,并且是按你所期望的方式进行的:从区间的起始点循还到结束点。有一些算法,比如find和find_if,可能在遍历完成前就返回了,但即使是这些算法,内部都包含一个循环。毕竟,即使是find和find_if也必须在查看过了每个元素后,才能断定它们所寻找的元素在不在此区间内。
所以,算法内部是一个循环。此外,STL算法的广泛涉及面意味着很多你本来要用循环来实现的任务,现在可以改用算法来实现了。比如,如果你有一个支持重画的Widget类:
class Widget {
public:
...
void redraw() const;
...
};
并且,你想重画一个list中的所有Widget对象,你可能会这样使用这样一个循环:
list<Widget> lw;
...
for (list<Widget>::iterator i =
lw.begin();
i != lw.end(); ++i) {
i->redraw();
}
但是你也可以用for_each算法来完成:
for_each(lw.begin(), lw.end(),
mem_fun_ref(&Widget::redraw));
对许多C++程序员而言,使用循环比调用算法的想法自然多了,并且读循环比弄懂mem_fun_ref的意义和取Widget::redraw的地址要舒服多了。但是,本条款将说明调用算法更可取。事实上,本条款将证明调用算法通常比手写的循环更优越。为什么?
有三个理由:
- 效率:算法通常比程序员产生的循环更高效。
- 正确性:写循环时比调用算法更容易产生错误。
- 可维护性:算法通常使代码比相应的显式循环更干净、更直观。
本条款的余下部分将对此予以例证。
从效率方面看,算法在三个方面可以打败显式循环,两个主要的,一个次要的。次要的包括消除了多余的计算。回头看一下我们刚才写的循环:
for (list<Widget>::iterator i =
lw.begin();
i != lw.end();
++i) {
i->redraw();
}
我已经加亮了循环终止测试语句,以强调每次循环,i都要与lw.end()作检查。也就是说,每次的循环,都要调用函数list::end。但我们不需要调用end()一次以上的,因为我们不准备修改这个list,end调用一次就够了。而我们转过来看一下算法调用,就可以看到只对end作了正确的求值次数:
for_each(lw.begin(), lw.end(), // 调用lw.end()求值
mem_fun_ref(&Widget::redraw)); // 只有一次
公平的说,STL的实现者知道begin和end(以及类似的函数,比如size)用得很频繁,所以他们尽可能把它们实现得最高效。几乎肯定会inline它们,并努力编码使得绝大部分编译器都可以通过将计算结果提到循环外(译注:频度优化的一种)来避免重复计算。然而,经验表明,这样的实现不是总可以成功的,而且当不成功时,对重复计算的避免足以让算法比手写的循环具有性能优势。
但这只是影响效率的次要因素。第一个主要影响因素是库的实现者可以利用他们知道容器的具体实现的优势,用库的使用者无法采用的方式来优化遍历。比如,在deque中的对象通常存储在(内部的)一个或多个固定大小的数组上。基于指针的遍历比基于迭代器的遍历更快,但只有库的实现者可以使用基于指针的遍历,因为只有他们知道内部数组的大小以及如何从一个数组移向下一个。一些STL容器和算法的实现版本将它们的deque的内部数据结构引入了account,而且已经知道,这样的实现比“通常”的实现快20%。
这点不是说STL实现这为deque(或者其他特定的容器类型)做了优化,而是实现者对于他们的实现比你知道得更多,而且他们可以把这些知识用在算法实现上。如果你避开了算法调用,而只喜欢自己写循环,你就相当于放弃了得到任何他们所提供的特定实现优点的机会。
第二个主要的效率因素是,除了最微不足道的STL算法,所有的STL算法使用的计算机科学都比一般的C++程序员能拿得出来的算法复杂——有时候会复杂得多。几乎不可能被打败的sort及其同族算法(比如,stable_sort(),nth_element()等,参见条款31);适用于有序区间的搜索算法(比如,binary_search,lower_bound等,参见条款34和35)也一样好;就算是很平凡的任务,比如从连续内存容器中除去一些对象,使用erase-remove惯用法都比绝大多数程序员写的循环更高效(参见条款9)。
如果算法的效率因素说服不了你,也许你更愿意接受基于正确性的考虑。写循环时,比较麻烦的事在于确保所使用的迭代器(a)有效,并且(b)指向你所期望的地方。举例来说,假设有一个数组(大概是因为遗留的C API——参见条款16),你想获得其中的每一个元素,把它加上41,然后将结果插入一个deque的前端。用循环,你可能这样写:(这是来自条款16的一个例子的变体):
// C API:这个函数的参数是一个能放arraySize
// 个double的数组的指针,
// 函数向这个数组写入数据。它返回
// 写入double的个数
size_t fillArray(double *pArray, size_t arraySize);
double data[maxNumDoubles]; // 建立本地数组,
// 大小是最大可能的大小
deque<double> d; // 建立deque,
... // 把数据放进去
size_t numDoubles =
fillArray(data, maxNumDoubles); // 从API获取数组数据
for (size_t i = 0; i < numDoubles; ++i) { // 对于每一个数据,
d.insert(d.begin(), data[i] + 41); // 在d的前端插入data[i]+41
} // 这段代码有一个bug!
这可以执行,只要你能满意于插入的元素于在data中对应的元素是反序的。因为每次的插入点都是d.begin(),最后一个被插入的元素将位于deque的前端!
如果这不是你想要的(还是承认吧,它肯定不是),你可能想这样修改:
deque<double>::iterator insertLocation = d.begin(); // 记下d的
// 起始迭代器
for (size_t i = 0; i < numDoubles; ++i) { // 在insertLocation
d.insert(insertLocation++, data[i] + 41); // 插入data[i]+41,然后
} // insertLocation递增;
// 这段代码也有bug!
看起来象双赢,它不只是递增了指示插入位置的,还避免了循环每次对begin的调用;这就像我们前面讨论过的一样,消除了影响效率的次要因素。唉,这种方法陷入了另外一个的问题中:它导致了未定义的结果。每次调用deque::insert,都将导致所有指向deque内部的迭代器无效,包括上面的insertLocation。在第一次调用insert后,insertLocation就无效了,后面的循环迭代器可以直接让人发疯。
注意到这个问题后(可能得到了STLport调试模式的帮助,在条款50描述),你可能会这样做:
deque<double>::iterator insertLocation =
d.begin(); // 同上
for (size_t i = 0; i < numDoubles; ++i) { // 每次调用insert后
insertLocation = // 都更新insertLocation
d.insert(insertLocation, data[i] + 41); // 以保证迭代器有效
++insertLocation; // 然后递增它
}
这样的代码确实完成了你相要的功能,但回想一下费了多大劲才达到这一步!和调用算法transform对比一下:
// 把所有元素从data拷贝到d的前端
// 每个增加上41
transform(data, data + numDoubles,
inserter(d, d.begin()),
bind2nd(plus<double>(), 41));
这个“bind2nd(plus<double>(), 41)
”可能会花上一些时间才能看明白(尤其是如果不常用STL的bind族的话),但是与迭代器相关的唯一烦扰就是指出源区间的起始点和结束点(而这从不会成为问题),并确保在目的区间的起始点上使用inserter(参见条款30)。实际上,为源区间和目的区间指出正确的初始迭代器通常都很容易,至少比确保循环体没有于无意中将需要持续使用的迭代器变得无效要容易得多。
难以正确实现循环的情况太多了,这个例子只是比较有代表性。因为在使用迭代器前,必须时刻关注它们是否被不正确地操纵或变得无效。要看忽略迭代器失效会导致的麻烦的另一个例子,转到条款9,那里描述了手写循环和调用erase只见的微妙之处。假设使用无效的迭代器会导致未定义的行为,又假设未定义的行为在开发和测试期间会有表现出令人讨厌的行为,为什么要冒不必要的危险?将迭代器扔给算法,让它们去考虑操纵迭代器时的各种诡异行为吧。
我已经解释了算法为什么可以比手写的循环更高效,也描述了为什么循环将艰难地穿行于与迭代器相关的荆棘丛中,而算法正避免了这一点。运气好的话,你现在已是一个算法的信徒了。然而运气是变化无常的,在我休息前,我想更确定些。因此,让我们继续行进到代码清晰性的议题。毕竟,最好软件应该是那些最清晰的、最容易懂的、能容易增强、维护和适用于新的环境的软件。虽然循环比较熟悉,但算法在这个长期的竞争中具有优势。
关键在于已知词汇的力量。在STL中约有70个算法的名字——总共超过100个不同的函数模板,每个重载都算一个。每个算法都完成一些精心定义的任务,而且有理由认为专业的C++程序员知道(或应该去看一下)每个算法都完成了什么。因此,当程序员调用transform时,他们认为对区间内的每个元素都施加了某个函数,而结果将被写到另外一个地方。当程序员调用replace_if时,他(她)知道区间内满足判定条件的对象都将被修改。当调用partition时,她(他)明白区间中所有满足判定条件的对象将被聚集在一起(参见条款31)。STL算法的名字传达了大量的语义信息,这使得它们比随机的循环清晰多了。
当你看见for、while或do的时候,你能知道的只是这是一种循环。如果要获得哪怕一点关于这个循环作了什么的信息,你就得审视它。算法则不用。一旦你看见调用一个算法,独一无二的名字勾勒出了它所作所为的轮廓。当然要了解它真正做了什么,你必须检查传给算法的实参,但这一般比去研究一个普通的循环结构要轻松得多。
明摆着,算法的名字暗示了其功能。“for”、“while”和“do”却做不到这一点。事实上,这一点对标准C语言或C++语言运行库的所有组件都成立。毫无疑问地,你能自己实现strlen, memset或bsearch,但你不会这么做。为什么不会?因为(1)已经有人帮你实现了它们,因此没必要你自己再做一遍;(2)名字是标准的,因此,每个人都知道它们做什么用的;和(3)你猜测程序库的实现者知道一些你不知道的关于效率方面的技巧,因此你不愿意错过熟练的程序库实现者可能能提供的优化。正如你不会去写strlen等函数的自己的版本,同样没道理用循环来实现出已存在的STL算法的等价版本。
我很希望故事就此结束,因为我认为这个收尾很有说服力的。唉,有句老话叫好事多磨。算法的名字比光溜溜的循环有意义多了,这是事实,但使用循环更能让人明白加诸于迭代器上的操作。举例来说,假设想要找出vector中第一个比x大又比y小的元素。这是使用循环的实现:
vector<int> v;
int x, y;
...
vector<int>::iterator i = v.begin(); // 从v.begin()开始迭代,直到
for( ; i != v.end(); ++i) { // 找到了适当的值或
if (*i > x && *i < y) break; // 到v.end()了
}
... // i现在指向那个值或等于v.end()
将同样的逻辑传给find_if是可能的,但是需要使用一个非标准函数对象适配器,比如SGI的compose2[1](参见条款50):
vector<int>::iterator i =
find_if(v.begin(), v.end(), // 查找第一个find the first value val
compose2(logical_and<bool>(), // 使val > x
bind2nd(greater<int>(), x), // 和val < y都
bind2nd(less<int>(), y))); // 为真的值val
即使没使用非标准组件,许多程序员也会反对说它远不及循环清晰,我也不得不同意这个观点(参见条款47)。
find_if的调用可以不显得那么复杂,只要将测试的逻辑封装入一个独立的仿函数类中:
template<typename T>
class BetweenValues:
public unary_function<T, bool> { // 参见条款40
public:
BetweenValues(const T& lowValue,
const T& highValue) // 使构造函数保存上下界
:lowVal(lowValue), highVal(highValue)
{}
bool operator()(const T& val) const //返回val是否在保存的值之间
{
return val > lowVal && val < highVal;
}
private:
T lowVal;
T highVal;
};
...
vector<int>::iterator i = find_if(v.begin(), v.end(),
BetweenValues<int>(x, y));
但这种方法有它自己的缺陷。首先,创建BetweenValues模板比写循环体要多出很多工作。就光数一下行数。循环体:1行;BetweenValues模板:24行。太不成比例了。第二,find_if正在找寻是什么的细节被从调用上完全割裂出去了,要想真的明白对find_if的这个调用,还必须得查看BetweenValues的定义,但BetweenValues一定被定义在调用find_if的函数之外。如果试图将BetweenValues声明在这个函数的调用内部,就像这样,
{ // 函数开始
...
template <typename T>
class BetweenValues:
public std::unary_function<T, bool> { ... };
vector<int>::iterator i =
find_if(v.begin(), v.end(),
BetweenValues<int>(x, y));
...
} // 函数结束
你会发现这不能编译,因为模板不能声明在函数内部。如果试图把BetweenValues做成一个类而不是模板来避开这个限制:
{ // 函数开始
...
class BetweenValues:
public std::unary_function<int, bool> { ... };
vector<int> iterator i =
find_if(v.begin(), v.end(),
BetweenValues(x, y));
...
} // 函数结束
你会发现仍然运气不佳,因为定义在函数内部的类是个局部类,而局部类不能绑定在模板的类型实参上(比如find_if所需要的仿函数类型)。很失望吧,仿函数类和仿函数类模板不能被定义在函数内部,不管它实现起来有多方便。
在算法调用与手写循环正在进行的较量中,关于代码清晰度的底线是:这完全取决于你想在循环里做的是什么。如果你要做的是算法已经提供了的,或者非常接近于它提供的,调用泛型算法更清晰。如果循环里要做的事非常简单,但调用算法时却需要使用绑定和适配器或者需要独立的仿函数类,你恐怕还是写循环比较好。最后,如果你在循环里做的事相当长或相当复杂,天平再次倾向于算法。因为长的、复杂的通常总应该封装入独立的函数。只要将循环体一封装入独立函数,你几乎总能找到方法将这个函数传给一个算法(通常是for_each),以使得最终代码直截了当。
如果你同意算法调用通常优于手写循环这个条款,并且如果你也同意条款5的区间成员函数优于循环调用单元素的成员函数,一个有趣的结论出现了:使用STL容器的C++精致程序中的循环比不使用STL的等价程序少多了。这是好事。只要能用高层次的术语——如insert、find和for_each,取代了低层次的词汇——如for、while和do,我们就提升了软件的抽象层次,并因此使得它更容易实现、文档化、增强和维护。
正如所有了解零件(Widget)的人所知道的,Widget有重量和最高速度:
class Widget {
public:
...
size_t weight() const;
size_t maxSpeed() const;
...
};
此外众所周知的是给Widget排序的自然方法是按重量。用于Widget的operator<表现成这样:
bool operator<(const Widget& lhs, const Widget& rhs)
{
return lhs.weight() < rhs.weight();
}
但是假设我们想建立一个按照最高速度排序Widget的multiset<Widget>
。我们知道multiset<Widget>
的默认比较函数是less<Widget>
,而且我们知道默认的less<Widget>
通过调用Widget的operator<
来工作。鉴于这种情况,好像得到以最高速度排序的multiset<Widget>
很明显一种方法是通过特化less<Widget>
来切断less<Widget>
和operator<
之间的纽带,让它只关注Widget的最高速度:
template<> // 这是一个std::less
struct std::less<Widget>: // 的Widget的特化;
public // 也是非常坏的主意
std::binary_function<Widget,
Widget, // 关于这个基类更多
bool> { // 的信息参见条款40
bool operator()(const Widget& lhs, const Widget& rhs) const
{
return lhs.maxSpeed() < rhs.maxSpeed();
}
};
这看起来既有欠考虑又确实有欠考虑,但你想到的理由可能不欠考虑。你是不是很奇怪它完全可以编译?很多程序员指出上述不只是一个模板的特化,而且是在特化std namespace中的模板。“std不应该是神圣的,为库的实现保留,而且超出了一般程序员可以达到的范围?”他们问,“编译器不该拒绝这个干预C++不朽的运转的尝试吗?”他们很奇怪。
通常,试图修改std里的组件确实是禁止的(而且这么做通常被认为是行为未定义的范畴),但是在一些情况下,修补是允许的。具体来说,程序员被允许用自定义类型特化std内的模板。特化std模板的选择几乎总是更为优先,但很少发生,这确实合理的。例如,智能指针类的作者经常想让他们的类在排序的时候行为表现得像内建指针,因此用于智能指针类型的std::less特化并不罕见。例如下面内容,是Boost库的shared_ptr的一部分,你可以在条款7和50中获悉智能指针:
namespace std {
template<typename T> // 这是一个用于boost::shared_ptr<T>
struct less<boost::shared_ptr<T> >: // 的std::less的特化
public // (boost是一个namespace)
binary function<boost::shared_ptr<T>,
boost::shared_ptr<T>,// 这是惯例的
bool> { // 基类(参见条款40)
bool operator()(const boost::shared_ptr<T>& a,
const boost::shared_ptr<T>& b) const
{
return less<T*>()(a.get(),b.get()); // shared_ptr::get返回
} // shared_ptr对象内的
// 内建指针
};
}
这不过分,当然也没有什么奇怪的,因为这个less的特化仅仅在排序上保证智能指针的行为与它们的内建兄弟相同。哎,我们试验性的用于Widget的less特化却比较奇怪。
C++程序员可以被原谅存在某些假设。例如,他们假设拷贝构造函数拷贝。(就像条款8证明的,没有依照这个约定可能导致惊人的行为。)他们假设对一个对象取地址就会产生一个指向那个物体的指针。(转向条款18去了解当这不为真时将发生什么。)他们假设像bind1st和not2这样的适配器可能应用于函数对象。(条款40解释了当不是这样时,事情是如何被破坏的。)他们假设operator+是加法(除了string,但是使用“+”表示字符串的连接已经有悠久的历史了),operator-是减法,operator==作比较。而且他们假设使用less等价于使用operator<。
operator<
不仅是实现less的默认方式,它还是程序员希望less做的。让less做除operator<以外的事情是对程序员预期的无故破坏。它与所被称为“最小惊讶的原则”相反。它是冷淡的。它是低劣的。它是坏的。你不该那么做。
特别是当没有理由时。在STL中没有一个用到less的地方你不能指定一个不同的比较类型。回到我们原先以最高速度排序的multiset<Widget>
的例子,我们要得到希望的结果所需的所有事情就是建立一个叫做除了less以外的几乎任何名字的仿函数类来对我们感兴趣的东西进行比较。嗨,这里有一个例子:
struct MaxSpeedCompare:
public binary_function<Widget, Widget, bool> {
bool operator()(const Widget& lhs, const Widget& rhs) const
{
return lhs.maxSpeed() < rhs.maxSpeed();
}
};
要创造我们的multiset,我们使用MaxSpeedCompare作为比较类型,因此避免了默认比较类型的使用(当然也就是less<Widget>
):
multiset<Widget, MaxSpeedCompare> widgets;
这条代码确切地说出了它的意思。它建立了一个Widget的multiset,按照仿函数类MaxSpeedCompare所定义方法排序。
对比这个:
multiset<Widget> widgets;
这个表示widgets是一个以默认方式排序的Widget的multiset。在技术上,那表示它使用了less<Widget>
,但是实际上每人都要假设那真的意味着它是按operator<
来排序。
不要通过把less的定义当儿戏来误导那些程序员。如果你使用less(明确或者隐含),保证它表示operator<。如果你想要使用一些其他标准排序对象,建立一个特殊的不叫做less的仿函数类。它真的很简单。
ptr_fun/mem_fun/mem_fun_ref系列是什么意思的?有时候你必须使用这些函数,有时候不用,总之,它们是做什么的?它们似乎只是坐在那里,没用地挂在函数名周围就像不合身的外衣。它们不好输入,影响阅读,而且难以理解。这些东西是STL古董的附加例子(正如在条款10和18中描述的那样),或者只是一些标准委员会的成员因为太闲和扭曲的幽默感而强加给我们的语法玩笑?
冷静一下。虽然ptr_fun、mem_fun和mem_fun_ref的名字不太好听,但它们做了很重要的工作,而不是语法玩笑,这些函数的主要任务之一是覆盖C++固有的语法矛盾之一。
如果我有一个函数f和一个对象x,我希望在x上调用f,而且我在x的成员函数之外。C++给我三种不同的语法来实现这个调用:
f(x); // 语法#1:当f是一个非成员函数
x.f(); // 语法#2:当f是一个成员函数
// 而且x是一个对象或一个对象的引用
p->f(); // 语法#3:当f是一个成员函数
// 而且p是一个对象的指针
现在,假设我有一个可以测试Widget的函数,
void test(Widget& w); // 测试w,如果没通过
// 就标记为“failed”
而且我有一个Widget的容器:
vector<Widget> vw; // vw容纳Widget要测试vw中的每个Widget,我很显然可以这么使用for_each:
for_each(vw.begin(), vw.end(), test); // 调用#1(可以编译)
但想象test是一个Widget的成员函数而不是一个非成员函数,也就是说,Widget支持自我测试:
class Widget {
public:
void test(); // 进行自我测试;如果没通过
// 就把*this标记为“failed”
};
在一个完美的世界,我也将能使用for_each对vw中的每个对象调用Widget::test:
for_each(vw.begin(), vw.end(),
&Widget::test); // 调用#2(不能编译)
实际上,如果世界真的完美,我将也可以使用for_each来在Widget*
指针的容器上调用Widget::test:
list<Widget*> lpw; // lpw容纳Widget的指针
for_each(lpw.begin(), lpw.end(),
&Widget::test); // 调用#3(也不能编译)
但是想想在这个完美的世界里必须发生的。在调用#1的for_each函数内部,我们以一个对象为参数调用一个非成员函数,因此我们必须使用语法#1。在调用#2的for_each函数内部,我们必须使用语法#2,因为我们有一个对象和一个成员函数。而调用#3的for_each函数内部,我们需要使用语法#3,因为我们将面对一个成员函数和一个对象的指针。因此我们需要三个不同版本的for_each,而那个世界将有多完美?
在我们拥有的世界上,我们只有一个版本的for_each。想一种实现不难:
template<typename InputIterator, typename Function>
Function for_each(InputIterator begin, InputIterator end, Function f)
{
while (begin != end) f(*begin++);
}
这里,我强调当调用时for_each是用语法#1这个事实。这是STL里的一个普遍习惯:函数和函数对象总使用用于非成员函数的语法形式调用。这解释了为什么调用#1可以编译而调用#2和#3不。这是因为STL算法(包括for_each)牢牢地绑定在句法#1上,而且只有调用#1与那语法兼容。
也许现在清楚为什么mem_fun和mem_fun_ref存在了。它们让成员函数(通常必须使用句法#2或者#3来调用的)使用句法1调用。
mem_fun和mem_fun_ref完成这个的方式很简单,虽然如果你看一下这些函数之一的声明会稍微清楚些。它们是真的函数模板,而且存在mem_fun和mem_fun_ref模板的几个变体,对应于它们适配的不同的参数个数和常量性(或缺乏)的成员函数。看一个声明就足以理解事情怎样形成整体的:
template<typename R, typename C> // 用于不带参数的non-const成员函数
mem_fun_t<R,C> // 的mem_fun声明。
mem_fun(R(C::*pmf)()); // C是类,R是被指向
// 的成员函数的返回类型
mem_fun带有一个到成员函数的指针,pmf,并返回一个mem_fun_t类型的对象。这是一个仿函数类,容纳成员函数指针并提供一个operator(),它调用指向在传给operator()的对象上的成员函数。例如,在这段代码中,
list<Widget*> lpw; // 同上
...
for_each(lpw.begin(), lpw.end(),
mem_fun(&Widget::test)); // 这个现在可以编译了
for_each接受一个mem_fun_t类型的对象,持有一个Widget::test的指针。对于在lpw里的每个Widget指针,for_each使用语法#1“调用”mem_func_t,而那个对象立刻在Widget指针上使用句法#3调用Widget::test。
总的来说,mem_fun适配语法#3——也就是当和Widget*
指针配合时Widget::test要求的——到语法1,也就是for_each用的。因此也不奇怪像mem_fun_t这样的类被称为函数对象适配器。知道这个不应该使你惊讶,完全类似上述的,mem_fun_ref函数适配语法#2到语法#1,并产生mem_fun_ref_t类型的适配器对象。
mem_fun和mem_fun_ref产生的对象不仅允许STL组件假设所有函数都使用单一的语法调用。它们也提供重要的typedef,正如ptr_fun产生的对象一样。条款40告诉了你这些typedef后面的故事,所以我将不在这里重复它。但是,这使我们能够理解为什么这个调用可以编译。
for_each(vw.begin(), vw.end(), test); // 同上,调用#1;
// 这个可以编译
而这些不能:
for_each(vw.begin(), vw.end(), &Widget::test); // 同上,调用#2;
// 不能编译
for_each(lpw.begin(), lpw.end(), &Widget::test); // 同上,调用#3;
// 不能编译
第一个调用(调用#1)传递一个真的函数,因此用于for_each时不需要适配它的调用语法;这个算法将固有地使用适当的语法调用它。而且,for_each没有使用ptr_fun增加的typedef,所以当把test传给for_each时不必使用ptr_fun。另一方面,增加的typedef不会造成任何损伤,所以这和上面的调用做相同的事情:
for_each(vw.begin(), vw.end(), ptr_fun(test)); // 可以编译,行为
// 就像上面的调用#1
如果你关于什么时候使用ptr_fun什么时候不使用而感到困惑,那就考虑每当你传递一个函数给STL组件时都使用它。STL将不在乎, 并且没有运行期的惩罚。可能出现的最坏的情况就是一些读你代码的人当看见不必要的ptr_fun使用时,可能会扬起眉毛。我认为,那有多让你操心依赖于你对扬起眉毛的敏感性。
一个与ptr_fun有关的可选策略是只有当你被迫时才使用它。如果当typedef是必要时你忽略了它,你的编译器将退回你的代码。然后你得返回去添加它。
mem_fun和mem_fun_ref的情况则完全不同。只要你传一个成员函数给STL组件,你就必须使用它们,因为,除了增加typedef(可能是或可能不是必须的)之外,它们把调用语法从一个通常用于成员函数的适配到在STL中到处使用的。当传递成员函数指针时如果你不使用它们,你的代码将永远不能编译。
现在只留下成员函数适配器的名字,而在这里,最后,我们有一个真实的STL历史产物。当对这些种适配器的需求开始变得明显时,建立STL的人们正专注于指针的容器。(这种容器的缺点在条款7、20和33描述,这看起来可能很惊人,但是记住指针的容器支持多态,而对象的容器不支持。)他们需要用于成员函数的适配器,所以他们选择了mem_fun。但很快他们意识到需要一个用于对象的容器的另一个适配器,所以他们使用了mem_fun_ref。是的,它非常不优雅,但是这些事情发生了。告诉我你从未给你的任何组件一个你过后意识到,呃,很难概括的名字……