移植贪吃蛇——从C#到C++

  欢迎参与讨论,转载请注明出处。

前言

  因为某些机缘巧合,引起了我对C++的重视。一时兴起,决定将两年前用Unity写的Snake进行移植。经过两周的抽空,总算是完成了。项目采用现代C++标准编写,采用CMake构建,图形库为SDL。由于本次的重点不在于图形这块,所以没有使用原版的素材,采用矩形代替。
  在工程实现上除了基本的业务外,还实现了C#的event以及的Unity的GameObject与Component。
  本文将从C#开发者的角度出发比较C++的不同点,最后总结其思想。由于本人在此之前从未有C++的工程经验,对于许多特性在此之前也是一知半解,对于一些事物的理解若有误还请指教。

低成本封装

  首先最引我瞩目的便是C++的参数传递,形如这般的函数:

1
void Init(const string& title, int width, int height);

  由于C++的引用参数string&性质,将值传入时不会发生拷贝,而是等于直接使用原变量。可以有效降低封装抽象的成本,加上const字段是为使得形如"123"这样的常量区对象也能传入。
  当然这在C#也并不是没有,ref便是如此。但这在C#并不会下意识去用,毕竟在C++若是不用指针或引用作为参数的话可是会直接拷贝新对象的,而在C#直接使用也不会造成很大的负担(值类型直接拷贝,引用类型用指针)。
  其次便是C++的内联函数了,作为函数宏的替代品之一。可以在编译时将函数展开为具体的内容,节省了一次函数调用的消耗。但内联函数需写在头文件中,若是关联项多,修改后便会增加编译时长。且展开量过大也会增大代码量,增加编译时长。但不失为一个降低封装成本的手段。

明确的内存

  其次与C#最大的不同便是对象的创建了,C++有着以下两种形式:

1
2
A a = A();
A* a = new A();

  了解C++的自然晓得,前者在当前内存域下申请,后者在堆申请。而在C#则隐去了这个细节,而是设立固定的规则:

  • 引用对象使用指针,原则上在堆申请,若对象的生命周期存在于申请的函数里,则在栈申请——是为逃逸分析
  • 值对象在当前内存域下申请,且由于不是指针,变量传递会产生拷贝。除非使用ref、in、out等参数关键字。

  而C++的内存申请机制则带来了明确感,如在函数里申请生命周期只存在函数里的对象,需要明确的使用A a = A();方式。且在构建类的时候,对于那些不使用A* a = new A();创建方式的成员变量,其内存占用是明确的,在类对象申请内存的时候会一并申请,即这些成员变量在内存布局上可能是连续的。从这点来说可比C#要牛逼多了。

相似的容器

  在容器方面,C++与C#大体看起来是相似的,当然在API的爽度而言还是C#更胜一筹(C++17拉近了不少)。但实际上还是存在一些细节上的不同,就比如我们常用的Key-Value容器:C++的std::map与C#的Dictionary在实现乃至功能上就不一样。实际上std::map对应C#的应该是SortedDictionary:它们都是基于红黑树实现,都是有序存储的表。而Dictionary则是基于哈希实现的,即我们俗称的哈希表,与之对应的是std::unordered_map
  通过命名能看出两种语言在这方面的倾向性:红黑树占用的内存更小,但查找和删除的时间复杂度都是O(logn),而哈希查找和删除的时间复杂度都是O(1)。实际使用的时候感觉还是得权衡利弊,不能贪图方便就一直用一套。std::setHashSet这边也是类似的对应,以此类推。
  在序列容器方面的对应倒是工整:std::vector对应List,都是不断扩容的数组容器。链表方面则是std::list对应LinkedList。但std::array却无对应了,硬要说的话就是与C#的原生数组对应,毕竟这个容器出现的意义就是弥补与C语言兼容的原生数组。
  顺带一提,在使用std::vector时由于会出现扩容复制的问题,需要考虑好成员对象的拷贝方案,乃至于内存泄漏的问题。

智能的指针

  内存管理是所有编程语言都无法绕开的点,绝大多数编程语言对于堆内存的管理都是采用垃圾回收的方式。而在C++的鸿蒙时代则与C语言一样,需要手动管理指向堆内存的指针。尽管也有std::auto_ptr这样的东西,但在功能上还不够全面。而手动管理内存将难以解决对象在多处被引用时将如何安全销毁的问题,为了实现这种机制也得做出不少妥协。
  所幸随着时代的发展,现代C++迎来了智能指针,它基于引用计数的规则,将裸指针包装起来,当符合销毁条件后便可自动回收。智能指针有着几种具体的类实现,而其中最常用的是std::share_ptr,当它持有指针时将增加计数,反之同理将减少计数,最终归0销毁。但其较之垃圾回收有个致命的缺陷:相互引用时将一直保持计数,无法销毁。为此C++引入了std::weak_ptr:它不会增加计数,在计数归0时持有指针也随之销毁。如此对于相互引用的情况下,分清主次,合理分配share_ptr与weak_ptr即可解决无法销毁的问题。
  智能指针在使用上总有一种外挂的感觉,需要成体系的去使用。不如内置的垃圾回收式语言来的方便,且写起来还是有一定的心智负担(相互引用),不过在性能而言较之垃圾回收更为优越(回收对象与时机都很明确,且是被动进行的)。

模板与泛型

  C++的模板与C#的泛型表面上用起来很是相似,实则有所不同。以下对比两者的差异:

1
template<class T, int x> // C++支持模板参数,可填写整型或指针

1
GenericList<T> where T : Employee // 使用System.Object不支持的方法时,需进行类型约束指定基类
1
2
3
4
5
6
7
// 这么骚的操作见过么?
void f(int x);
template <class ... Args>
void Do(Args... args) {
f(args ...);
};

  从实际使用体验与两者的命名可以看出,「模板」的本质是参数化代码生成,而「泛型」则是类型参数化。即泛型只是模板功能的一部分而已。模板能实现的其他功能,在C#则以其他方式代替了(如变长参数params)。

后记

  从以上种种便能看出C++与C#在设计哲学上的不同,C#通过约束开发者行为从而达到更稳定健壮的结果,哪怕会失去一定的性能与灵活性,而C++则更依赖开发者自身的素质(如C++支持多重继承而C#仅仅支持单类+多接口继承)。
  从个人的使用体验来看,现代C++并非不能作为业务开发语言。只是对开发者的素质要求较之一般语言更高,从招聘成本与项目稳定性而言是个问题。如此来看,除非有必要的性能敏感且需要一定封装的核心层(如游戏引擎),否则用C + 脚本语言或者C#/Java这类可上可下的语言是个更好的选择。