一、关联性容器(map vs TreeMap):
我们在用Java进行编程时经常会用到Map这样的容器接口,而它的具体实现类一般来自于TreeMap和HashMap,前者提供了有序性,而后者则在存取效率方面有着一定的优势。相比于Java,C++的STL也提供了map容器,其功能等同于Java中的TreeMap,至于HashMap,它并没有进入C++标准模板库。而幸运的是,现有的主流C++编译器供应商均提供了标准模板库的扩展包,并在该扩展包中实现了hash_map容器类,其接口标准和STL中的容器基本一致。
在本条目中我们将主要列举TreeMap(Java)和map(C++)在设计上的主要差别,以及关键功能实现上的明显差异,并基于此给出在实际代码移植过程中该如何应对这些差异所带来的诸多问题:
1. 插入相同键时两种容器表现不同,其中Java的TreeMap会将后插入的键值对直接覆盖容器中已经存在的键值对,而C++中的map容器则恰恰相反,一旦容器中该键已经存在,那么新插入的键值对将不会被存入该容器对象中,见如下Java代码及输出结果。
1 public class MyTest {
2 private static void testGet() {
3 TreeMap<String,String> tm = new TreeMap<String,String>();
4 tm.put("Hello", "World");
5 if (tm.get("Hello1") == null)
6 System.out.println("Hello1 is NOT found.");
7 }
8
9 public static void main(String[] args) {
10 TreeMap<Integer,Integer> tm = new TreeMap<Integer,Integer>();
11 tm.put(5, 10);
12 tm.put(5, 20);
13 Set<Entry<Integer,Integer>> entries = tm.entrySet();
14 Iterator<Entry<Integer,Integer>> it = entries.iterator();
15
16 while (it.hasNext()) {
17 Entry<Integer,Integer> e = it.next();
18 System.out.printf("key = %d, value = %d\n",e.getKey().intValue(),e.getValue().intValue());
19 }
20 }
21 }
22 //The count of the TreeMap is 1
23 //key = 5, value = 20
见如下C++代码及输出结果。
1 using namespace std;
2 int main()
3 {
4 map<int,int> m;
5 m.insert(make_pair(5,10));
6 m.insert(make_pair(5,20));
7 printf("The count of the map is %d.\n",m.size());
8 map<int,int>::iterator it = m.begin();
9 for (; it != m.end(); ++it) {
10 printf("key = %d, value = %d\n",(*it).first,(*it).second);
11 }
12 return 0;
13 }
14 //The count of the map is 1
15 //key = 5, value = 10
从以上结果便可以清晰的看出二者之间的差别,那么我们又该如何修改C++的代码,以使其和原有的Java代码逻辑保持一致呢?现在答案已经非常清楚了,我们只需在插入前先行判断该键是否存在,如果存在则删除已有键值对,之后再将新键值对插入到该容器对象中,或者通过已有键的迭代器访问该键的值,并赋予新值。至于该使用哪种方式,则需要依据实际情况而定,通常而言,第一种方法可以应对所有场景,但是效率相比于第二种方法要略低一些。见如下修改后的C++代码:
1 using namespace std;
2 int main()
3 {
4 map<int,int> m;
5 m.insert(make_pair(5,10));
6 map<int,int>::iterator it = m.find(5);
7
8 if (it != m.end())
9 m.erase(it);
10 m.insert(make_pair(5,20));
11 //或实现为以下方式
12 //if (it != m.end())
13 // it->second = 20;
14 //else
15 // m.insert(make_pair(5,20));
16
17 printf("The count of the map is %d.\n",m.size());
18 it = m.begin();
19 for (; it != m.end(); ++it)
20 printf("key = %d, value = %d\n",(*it).first,(*it).second);
21 return 0;
22 }
23 //The count of the map is 1
24 //key = 5, value = 10
2. 在Java中,TreeMap的参数类型必须为对象类型,不能为原始数据类型,如int、double等,否则编译器将报编译错误。而在C++中,对模参的类型则没有此类限制。如果当Java源代码中TreeMap的值参数类型为自定义对象时,那么在C++中与其对应的值模参类型很有可能为该自定义对象的指针类型,否则将只能是该对象类型本身,这样在插入对象时,就会有对象copy的动作发生,从而对性能造成一定的负面影响,通常而言,我们会选择使用该类的指针类型作为该容器的值模参类型。见如下Java代码:
1 public class MyTest {
2 private int _value;
3 public MyTest(int value) {
4 _value = value;
5 }
6
7 public static void main(String[] args) {
8 TreeMap<Integer,MyTest> tm = new TreeMap<Integer,MyTest>();
9 tm.put(5, new MyTest(10));
10 System.out.println("The count of the TreeMap is " + tm.size());
11 Set<Entry<Integer,MyTest>> entries = tm.entrySet();
12 Iterator<Entry<Integer,MyTest>> it = entries.iterator();
13 while (it.hasNext()) {
14 Entry<Integer,MyTest> e = it.next();
15 System.out.printf("key = %d, value = %d\n",e.getKey().intValue(),e.getValue()._value);
16 }
17 }
18 }
19 //The count of the TreeMap is 1
20 //key = 5, value = 10
以上为Java的完整代码和输出结果。如果我们仍然用该种方式完成C++的代码实现,这样正确吗?答案是输出结果是一致的,但是在C++代码中却存在着一个极为低级的失误。对,内存泄露。见如下C++代码及代码注释:
1 using namespace std;
2 class MyTest
3 {
4 public:
5 MyTest(int value) {
6 _value = value;
7 }
8 ~MyTest() {}
9 public:
10 int getValue() const {
11 return _value;
12 }
13 private:
14 int _value;
15 };
16
17 int main()
18 {
19 map<int,MyTest*> m;
20 m.insert(make_pair(5,new MyTest(10)));
21 map<int,MyTest*>::iterator it = m.find(5);
22 printf("The count of the map is %d.\n",m.size());
23 it = m.begin();
24 for (; it != m.end(); ++it)
25 printf("key = %d, value = %d\n",(*it).first,(*it).second->getValue());
26 return 0;
27 }
28 //The count of the map is 1
29 //key = 5, value = 10
在执行以上代码之后,如果借助于Valgrind(Linux gcc)或BoundChecker(Visual C++)等内存检测工具,便可以清楚的看到插入的MyTest对象指针在程序退出之前没有被正常释放,从而导致了内存泄露。见如下修订后的C++代码:
1 //该宏是我在实际项目中经常用到的工具宏之一
2 #define RELEASE_MAP(Type1,Type2,variable) \
3 do {\
4 map<Type1,Type2*>::iterator it = (variable).begin();\
5 for (; it != (variable).end(); ++it) \
6 delete (it->second); \
7 (variable).clear(); \
8 } while(0)
9
10 int main()
11 {
12 map<int,MyTest*> m;
13 m.insert(make_pair(5,new MyTest(10)));
14 map<int,MyTest*>::iterator it = m.find(5);
15 printf("The count of the map is %d.\n",m.size());
16 it = m.begin();
17 for (; it != m.end(); ++it)
18 printf("key = %d, value = %d\n",(*it).first,(*it).second->getValue());
19 RELEASE_MAP(int,MyTest,m);
20 return 0;
21 }
修订后代码的输出结果与之前的代码是完全相同的,只是容器中对象指针的内存泄露问题就此得以解决。然而这种类似的内存资源管理问题给你带来的困扰,将会在整个代码移植的过程中贯穿始末。有的时候我甚至直接忽略了内存资源泄露问题,并将C++的代码写的和Java中的源码完全一致,直到某一时刻才突然惊醒的发现,之后就是不得不沮丧的进行重构,按照C++的机制和习惯方式重新实现该部分逻辑。
这里我们将不再就此类问题做过多的赘述和解释,后面会有一个单独的条目来演示和分析该类问题。
3. 作为有序的关联性容器,键对象的排序机制自然是一个无法回避的问题,但幸运的是,尽管这两种容器在某些处理细节上存在一定的差异,然而整个排序机制的思路却是如出一辙,可以这样说,它们是神似而形不似,而造成“形不似”的主要原因则主要来自于这两种语言的本身。下面我们先介绍Java中TreeMap的键对象排序机制。
1). 一种常用的方式是让键对象成为Comparable<T>接口的实现类,这样TreeMap的内部则可以利用接口方法compareTo()的返回值来判断该键对象在容器中所在的位置。见如下代码:
1 public class MyTest implements Comparable<MyTest> {
2 private int key;
3 public MyTest(int key) {
4 this.key = key;
5 }
6 @Override
7 public boolean equals(Object o) {
8 if (!(o instanceof MyTest))
9 return false;
10 MyTest t = (MyTest)o;
11 System.out.println("equals of MyTest is called here.");
12 return key == t.key;
13 }
14 @Override
15 public int compareTo(MyTest o) {
16 return key - o.key;
17 }
18 public static void main(String[] args) {
19 TreeMap<MyTest,Integer> tm = new TreeMap<MyTest,Integer>();
20 tm.put(new MyTest(5), 5);
21 tm.put(new MyTest(2), 2);
22 tm.put(new MyTest(10), 10);
23 tm.put(new MyTest(10), 20);
24 for (Entry<MyTest,Integer> e : tm.entrySet())
25 System.out.println("Key = " + e.getKey().key + ", Value = " + e.getValue());
26 }
27 }
28 //compareTo of MyTest is called here.
29 //compareTo of MyTest is called here.
30 //compareTo of MyTest is called here.
31 //compareTo of MyTest is called here.
32 //Key = 2, Value = 2
33 //Key = 5, Value = 5
34 //Key = 10, Value = 20
从输出结果可以看出,MyTest对象在被插入到容器时,该类覆盖实现的compareTo方法被多次调用,而且最终的排序结果也是和compareTo方法中的逻辑完全吻合。这里还需要额外指出的是,TreeMap和C++中的map一样,都是不允许重复键的插入,从而保证容器对象中已有键对象的唯一性。这是如何实现的呢?在Java中,当compareTo函数返回0的时候表示两个对象是相等的,我们从上例中可以看出,通常用于JDK对象相等性比较的equals函数并没有被调用,由此可以进一步确认,键对象的相等性比较完全是由compareTo函数来决定的。在此方面,C++中的map容器亦采用了相同的设计理念,只是在细节处理方面不同且更为灵活。我们将在本条目的后面给出更具体的说明。
2). 另外一种方式是在构造TreeMap对象时传入Comparator<T>接口的实现类,而该实现类的compare方法将帮助TreeMap对象来确定如何排序容器中的键对象。见如下代码:
1 public class MyTest {
2 private int key;
3 public MyTest(int key) {
4 this.key = key;
5 }
6 public static void main(String[] args) {
7 TreeMap<MyTest,Integer> tm = new TreeMap<MyTest,Integer>(new Comparator<MyTest>() {
8 @Override
9 public int compare(MyTest first,MyTest second) {
10 System.out.println("compare of Comparator is called here.");
11 return first.key - second.key;
12 }
13 });
14 tm.put(new MyTest(5), 5);
15 tm.put(new MyTest(2), 2);
16 tm.put(new MyTest(10), 10);
17 for (Entry<MyTest,Integer> e : tm.entrySet())
18 System.out.println("Key = " + e.getKey().key + ", Value = " + e.getValue());
19 }
20 }
21 //compare of Comparator is called here.
22 //compare of Comparator is called here.
23 //Key = 2, Value = 2
24 //Key = 5, Value = 5
25 //Key = 10, Value = 10
从上面的输出结果可以看出,Comparator匿名实现类的compare方法确实决定了TreeMap容器中键对象的排序顺序。
现在是时候该展示一下C++中map容器的排序机制了,这里我们先看一下map的类声明形式。
template <class Key, class Type, class Traits = less<Key>, class Allocator=allocator<pair <const Key, Type> > > class map
其中前两个模参分别对应于键对象类型和值对象类型,这一点和Java中TreeMap是一样的。第三个模板参数则在map容器键对象的排序规则上扮演着极为重要的角色。至于最后一个参数,主要用于对象的分配机制,由于和本条目关系不大,我们可以直接略过它。
1). 从该类的模参声明中可以看到STL的设计者为模参Traits提供了缺省类型参数less<Key>,该类型仍为模板类,其模参类型等同于map中的键类型。这里我们先看一下less<Key>的声明。
1 template<class _Ty>
2 struct less : public binary_function<_Ty, _Ty, bool> {
3 bool operator()(const _Ty& _Left, const _Ty& _Right) const {
4 return (_Left < _Right);
5 }
6 };
该类只有一个公有方法,即圆括号操作符重载函数,在C++中通常称这种类型的类为函数类。该方法的实现逻辑要求模参类型必须具备“小于”的可比较性,对于原始数据类型这是没有问题的,如int,short,float等。相比而言,对于自定义的类来说,则必须重载小于操作符。见如下C++代码:
1 using namespace std;
2 class MyTest
3 {
4 public:
5 MyTest(int value) {
6 _value = value;
7 }
8 ~MyTest() {}
9 public:
10 bool operator< (const MyTest& o) const {
11 printf("operator < of MyTest is called here.\n");
12 return _value - o._value < 0;
13 }
14 public:
15 int getValue() const {
16 return _value;
17 }
18 private:
19 int _value;
20 };
21
22 int main()
23 {
24 map<MyTest,int> m;
25 MyTest t5(5);
26 MyTest t2(2);
27 MyTest t10(10);
28 m.insert(make_pair(t5,5));
29 m.insert(make_pair(t2,2));
30 m.insert(make_pair(t10,10));
31 map<MyTest,int>::iterator it = m.begin();
32 printf("The count of the map is %d.\n",m.size());
33 for (; it != m.end(); ++it)
34 printf("key = %d, value = %d\n",(*it).first.getValue(),(*it).second);
35 return 0;
36 }
37 //operator < of MyTest is called here.
38 //operator < of MyTest is called here.
39 //operator < of MyTest is called here.
40 //operator < of MyTest is called here.
41 //operator < of MyTest is called here.
42 //The count of the map is 3
43 //key = 2, value = 2
44 //key = 5, value = 5
45 //key = 10, value = 10
从上面的输出结果可以看到MyTest的小于操作符重载函数被多次调用,而且该函数的逻辑也确实应用于map容器对象的排序中。有意思的是我们仅仅插入三个键值对到该map容器,然而该函数却被调用5次,这是为什么呢?很简单,和Java的compareTo方法的返回值不同,该方法的返回值为布尔类型,只能表示小于或者不小于,无法标明新对象的键值和容器中已经存在对象的键值是否相等。为了弥补这一问题,在新对象被插入到容器中的正确位置之前,map容器的insert方法需要从正反两个方向将新元素的键值与容器中部分键对象进行比较,如果在与同一个对象进行两次比较之后均返回true,则证明该键值已经存在,否则将新对象插入指定的位置。
2). 在上面的示例代码中,map<MyTest,int>类型的键类型为MyTest对象类型,因此在每次插入新数据时都会产生一次键对象的拷贝构造,从而影响了插入效率。如果插入的是对象指针类型,则可以避免对象copy的发生,然而不幸的是,less<Key>会将对象指针视为整型,因此MyTest的小于操作符重载将不会被调用,那么我们也就无法得到我们期望的排序结果,因为less<Key>只是基于指针地址做了简单而毫无意义的整数比较。那么该如何修订这一问题呢?见如下代码示例。
1 using namespace std;
2 class MyTest
3 {
4 public:
5 MyTest(int key) {
6 _key = key;
7 }
8 ~MyTest() {}
9
10 public:
11 int _key;
12 };
13 struct MyTestComparator {
14 bool operator()(MyTest* first,MyTest* second) const {
15 printf("operator() of MyTestComparator is called here.\n");
16 return first->_key - second->_key < 0;
17 }
18 };
19
20 using namespace std;
21 int main()
22 {
23 MyTest* mt0 = new MyTest(0);
24 MyTest* mt2 = new MyTest(2);
25 MyTest* mt1 = new MyTest(1);
26 map<MyTest*,int,MyTestComparator> mtMap;
27 mtMap.insert(make_pair(mt0,0));
28 mtMap.insert(make_pair(mt2,2));
29 mtMap.insert(make_pair(mt1,1));
30 printf("The count of map is %d.\n",mtMap.size());
31 map<MyTest*,int,MyTestComparator>::iterator it = mtMap.begin();
32 for (; it != mtMap.end(); ++it) {
33 printf("key = %d, value = %d\n",(*it).first->_key,(*it).second);
34 delete (*it).first;
35 }
36 return 0;
37 }
38 //operator() of MyTestComparator is called here.
39 //operator() of MyTestComparator is called here.
40 //operator() of MyTestComparator is called here.
41 //operator() of MyTestComparator is called here.
42 //operator() of MyTestComparator is called here.
43 //operator() of MyTestComparator is called here.
44 //operator() of MyTestComparator is called here.
45 //operator() of MyTestComparator is called here.
46 //The count of the map is 3
47 //key = 0, value = 0
48 //key = 1, value = 1
49 //key = 2, value = 2
在上面的代码中我们并没有使用map中缺省的less<Key>对象来比较容器中的键对象,而是自己重新定义了一个类来专门比较MyTest*类型的键。这一点有些类似于Java中的Comparator<T>接口。
最后我们简单的总结一下Java和C++的泛型技术,由于C++是在语言设计之初就已经将模板和泛型等特征考虑了进来,而Java则是半路出家,因此C++在这方面的支持也就更灵活,更具伸缩性,在运行时也更加高效。