C# 转 C++ 的注意事项

26

我一直在开发一个项目,其中必须部分使用C++。我需要开发一个包装器,并将一些C++功能暴露给我的C#应用程序。自.NET刚开始以来,我一直是一名C#工程师,对C++几乎没有太多经验。当尝试理解语法时,它仍然对我来说非常陌生。

有什么东西会让我无法入手,阻止我去学习C++吗?


3
你是在使用纯C++还是C++/CLI? - Simon T.
10
C++ 和 C# 是完全不同的编程语言,它们之间共同点极少。 - anon
1
它可能不会让你眼前一亮,但我发现在C++中忘记用;结束一个类定义时最令人烦恼...我的意思是,为什么呢?}不是已经说明了一切吗? - Noldorin
12
@Noldorin 不行:结构体 A {} a; - anon
@Noldorin - 就个人而言,现在我必须经常在两种语言之间切换,我希望 C# 保留了 ; 即使它在那里是多余的。还有,使用 public: private: protected: 作为伪标签而不是对各个成员进行修改。并且将私有继承作为默认选项(尽管我认为 CLR 的类型系统不允许这样做)。 - Daniel Earwicker
使用Qt,您可以使用消息传递(信号/槽)来进行线程通信。QString类似于C#字符串。 - Aminos
14个回答

30

C++有很多问题,我无法全部列举。可以搜索"C# vs C++"。以下是一些基本知识:

  • struct和class基本上是相同的(默认可见性为public,class为private)。
  • 无论是在堆上还是栈上都可以创建struct和class。
  • 您必须自己管理堆。如果使用"new"创建了某个对象,则必须在某个时间点手动删除它。
  • 如果性能不是问题,并且要移动的数据量很小,则可以通过将所有内容放在栈上并使用引用(&运算符)来避免内存管理问题。
  • 学会处理.h和.cpp文件。未解决的外部问题可能会成为你的噩梦。
  • 不应从构造函数中调用虚方法。编译器永远不会告诉你,所以我告诉你。
  • switch语句不强制执行“break”,默认情况下继续执行。
  • 没有接口这样的东西。相反,您可以使用具有纯虚拟方法的类。
  • C++发烧友是危险的人,他们住在洞里,靠C#/Java程序员的新鲜血液生存。与他们谈论他们最喜爱的语言时要小心。

4
我不同意第四点。通常情况下,通过避免堆分配可以获得最佳性能。这样可以避免过多的new/delete调用带来的开销,并且可以获得更好的缓存局部性。 - jalf
1
给Jalf:说得好。这确实取决于数据的大小以及您对其执行的操作。我认为指针被高估了,我们可以在没有它们的情况下做很多事情。 - Simon T.
关于#6:在C++中在构造函数或析构函数中调用虚函数是完全合法的,并且具有明确定义的效果,因此不奇怪编译器不会报错。然而,Scott Meyers警告说“你不应该在构造函数或析构函数中调用虚函数,因为这些调用不会做你想要的事情,即使它们做了,你仍然会感到不满意”(参见例如http://www.artima.com/cppsource/nevercall.html)。 - user192472
关于#6:虚拟调用一个虚方法可能不会得到你想要的结果,但静态调用可能会。 - Thomas Eding
如果您从构造函数调用虚方法,这意味着您是一个糟糕的程序员,应该远离面向对象编程,回到现实世界。 - Aminos
显示剩余2条评论

26

垃圾回收!

记住每次新建一个对象时,您必须负责调用delete来销毁它。


24
你必须使用智能指针或其他管理内存的类型。当然,可能你在第一次使用“new”的时候就不应该使用它——过度使用动态分配是C#和Java程序员在使用C++时遇到的另一个问题。 - anon
2
非常好的观点。你需要真正掌握堆栈和堆之间的区别:http://www.learncpp.com/cpp-tutorial/79-the-stack-and-the-heap/ - Antony Woods
3
@Neil:我认为这是OP应该注意的最大陷阱。这真的应该是一个答案 :) 每个花了至少3分钟看C++的C#程序员都知道你必须调用“delete”。他们通常应该避免使用“new”才更令人惊讶。 - jalf

18

有很多不同之处,但我能想到程序员从Java/C#转来时总是犯错的最大问题,而他们从未意识到自己犯了错误,那就是C ++的值语义。

在C#中,您习惯于在想创建对象时使用new。每当我们谈论类实例时,我们真正指的是“对类实例的引用”。Foo x = y并不会复制对象y,它只是创建了另一个对象y引用的引用。

在C ++中,本地对象(无需使用new分配)(Foo fFoo f(x, y))和动态分配的对象(Foo * f = new Foo()Foo * f = new Foo(x, y))之间有明显的区别。并且按照C#的术语,所有东西都是值类型。 Foo x = y实际上创建了 Foo 对象本身的副本

如果您需要引用语义,则可以使用指针或引用:Foo& x = y创建对对象y的引用。Foo* x = &y创建指向y所在地址的指针。复制指针只是创建另一个指针,它指向原始指针指向的任何内容。因此,它类似于C#的引用语义。

本地对象具有自动存储持续时间 - 也就是说,在超出其范围时,本地对象会自动销毁。如果它是类成员,则在拥有对象销毁时销毁。如果它是函数内部的局部变量,则在执行离开声明它的作用域时销毁。

直到您调用delete才会销毁动态分配的对象。

到目前为止,您可能会同意我的观点。新手学习C ++很快就会了解这一点。麻烦的部分是关于这个意味着什么,以及它如何影响您的编程风格:

在C ++中,默认应该是创建本地对象。除非确实需要,否则不要使用new进行分配。

如果确实需要动态分配数据,请使其成为类的责任。以下是(非常)简化的示例:

class IntArrayWrapper {
  explicit IntArrayWrapper(int size) : arr(new int[size]) {} // allocate memory in the constructor, and set arr to point to it
  ~IntArrayWrapper() {delete[] arr; } // deallocate memory in the destructor

  int* arr; // hold the pointer to the dynamically allocated array
};

这个类现在可以作为一个本地变量被创建,它会内部执行必要的动态分配。当它超出范围时,它将自动删除已分配的数组。

假设我们需要一个长度为 x 的整数数组,而不是像这样:

void foo(int x){
  int* arr = new int[x];
  ... use the array ...
  delete[] arr; // if the middle of the function throws an exception, delete will never be called, so technically, we should add a try/catch as well, and also call delete there. Messy and error-prone.
}

你可以这样做:

void foo(int x){
  IntArrayWrapper arr(x);
  ... use the array ...
  // no delete necessary
}

当然,使用局部变量而不是指针或引用会导致对象经常被复制:

Bar Foo(){
  Bar bar;
  ... do something with bar ...
  return bar;
}
在上面的代码中,我们返回的是bar对象的一个副本。虽然我们可以返回一个指针或者引用,但是因为在函数内部创建的实例会在函数返回时被销毁并且超出了作用域,所以我们无法指向它。我们可以使用new来分配一个长于函数生存期的实例,并返回对该实例的引用 —— 然后我们就需要管理所有内存的释放问题,需要弄清楚责任应该由谁承担以及何时进行删除。这不是一个好主意。
相反,Bar类应该设计成只需复制即可完成我们所需的操作。也许它应该在内部调用new来分配一个可以在需要时生存的对象。然后我们可以通过复制或赋值“偷走”那个指针。或者我们可以实现某种引用计数方案,在复制对象时只需增加引用计数并复制指针 —— 这样应该在最后一个对象被销毁并且引用计数达到0时而非单个对象被销毁时删除指针。
但是通常情况下,我们可以执行深度复制,完全克隆对象。如果对象包含动态分配的内存,我们为副本分配更多的内存。这听起来可能很昂贵,但是C++编译器擅长消除不必要的复制(即使它们具有副作用,在大多数情况下也被允许消除复制操作)。
如果你想避免更多的复制,并且准备接受一些更为笨拙的使用方法,你可以在你的类中启用“移动语义”,以及(或者)“复制语义”。这是值得养成的习惯,因为(a)某些对象不能轻易地复制,但它们可以移动(例如Socket类),(b)它是标准库中已经建立的模式,以及(c)它将在下一个版本中获得语言支持。
有了移动语义,你可以将对象用作一种“可传输”的容器。移动的是内容。在当前的方法中,通过调用swap来完成,该函数交换两个相同类型的对象的内容。当一个对象超出作用域时,它被销毁,但是如果你先将它的内容交换到引用参数中,那么内容就会逃脱销毁。因此,你不一定需要全程使用引用计数智能指针来允许从函数返回复杂的对象。问题在于你无法真正返回它们——你必须将它们交换到一个引用参数中(与C#中的ref参数类似)。但是,在下一个版本的C++中的语言支持将解决这个问题。
所以,我能想到的最大的C#到C++的陷阱是:不要将指针作为默认。使用值语义,并调整你的类以在复制、创建和销毁时表现出期望的行为。

几个月前,我试图为您这种情况的人编写一系列博客文章:
第一部分
第二部分
第三部分

虽然我对它们的效果不是完全满意,但您仍然可能会发现它们有用。

当您感到永远无法掌握指针时,这篇文章可能会有所帮助。


我迫不及待地想编辑一下,包括提到swap——这是一种笨拙但有效的实现移动语义的方法,比完全采用引用计数简单得多。在函数内部构建一个大对象,然后通过将其与非const引用参数交换来“返回”它。 - Daniel Earwicker
我决定不描述太多具体细节(我也没有提及智能指针,尽管我几乎描述了它们的精确语义),但如果您觉得相关,请随意编辑。你是对的,swap 是使 C++ 的值语义正常工作的重要部分。 - jalf
如果您认为这是一个疯狂的离题,请删除它。 - Daniel Earwicker
在C#中:“Foo x = y”不会复制对象,它只是将x的引用设置为y的引用,如果Foo是一个类对象引用。 - John Foll

6

无运行时检查

C++ 中的一个陷阱是当你尝试做一些可能无效的事情时,但只有在运行时才能检查 - 例如,解引用可能为空的指针,或使用可能超出范围的索引访问数组。

C# 的哲学强调正确性; 所有的行为都应该是明确定义的,在这种情况下,它会对前提条件进行运行时检查,如果失败则抛出明确定义的异常。

C++ 的哲学强调效率和你不应该为任何可能不需要的东西付出代价。在这种情况下,没有任何内容会为您进行检查,因此您必须自己检查前提条件或设计逻辑使其成立。否则,代码将具有未定义的行为,这意味着它可能会(或多或少)做您想要的事情,它可能会崩溃,或者它可能会破坏完全不相关的数据并导致难以追踪的错误。


1
是的,我认为这是基本的根本区别。要理解一种语言的运行时语义,您需要查看规范。C++规范基于区分具有定义结果和具有未定义结果的操作的方法。CLR的“安全”子集(这是大多数语言/人们关心的所有内容)基于完全定义的行为概念。 - Daniel Earwicker
请注意:大部分良好的 C++ 实现都会定义某些在标准中未定义的操作行为。例如,调整 vector 的大小会使得任何相关迭代器无效——试图使用这样的迭代器将导致未定义的行为。但在许多实现中,在“调试”模式下,它通常会可靠地输出有用的消息。 - Daniel Earwicker
C++的哲学强调效率,以及你不应该为你可能不需要的任何东西付费的想法。 - Destructor

4

还有一些其他的东西,其他答案没有提到:

const:C#对常量的概念比较有限。在C++中,“常量正确性”很重要。不修改其引用参数的方法应采用const引用。例如:

void func(const MyClass& x)
{
    // x cannot be modified, and you can't call non-const methods on x
}

不修改对象的成员函数应标记为const,即:

int MyClass::GetSomething() const // <-- here
{
    // Doesn't modify the instance of the class
    return some_member;
}

这可能看起来多余,但实际上非常有用(请参见下一点关于临时对象的内容),有时是必需的,因为像STL这样的库完全支持const-correct,您不能将const转换为non-const(不要使用const_cast!永远不要!)。对于调用者知道某些内容不会被更改也很有用。最好这样考虑:如果省略const,则表示对象将被修改。
临时对象:正如另一个答案所提到的,C++更多地涉及值语义学。 临时对象可以在表达式中创建和销毁,例如:
std::string str = std::string("hello") + " world" + "!";

这里,第一个加号创建了一个临时字符串"hello world"。第二个加号将临时字符串与"!"组合在一起,生成一个包含"hello world!"的临时字符串,然后将其复制到 str 中。在语句完成后,临时变量立即被销毁。为了进一步复杂化事情,C++0x添加了右值引用来解决这个问题,但这超出了本答案的范围!您也可以将临时对象绑定到const引用(const的另一个有用部分)。再次考虑前面的函数:
void func(const MyClass& x)

你可以使用临时的 MyClass 显式地调用它:

func(MyClass()); // create temporary MyClass - NOT the same as 'new MyClass()'!

创建一个MyClass实例,在栈上,func2访问它,然后临时的MyClass在func返回后自动被销毁。这既方便又通常非常快速,因为不涉及堆。请注意,“new”返回指针 - 而不是引用 - 并且需要相应的“delete”。您还可以直接将临时变量赋值给const引用:

const int& blah = 5;   // 5 is a temporary
const MyClass& myClass = MyClass(); // creating temporary MyClass instance
// The temporary MyClass is destroyed when the const reference goes out of scope

常量引用和临时变量在良好的C++风格中很常见,它们的工作方式与C#非常不同。

RAII,异常安全和确定性析构函数。这实际上是C ++的一个有用特性,可能甚至是优于C#的优势,值得阅读,因为它也是好的C ++风格。我不会在这里详细介绍。

最后,我只想提醒this是一个指针,而不是引用 :)


2
从C#或Java转到C ++的传统障碍是内存管理和多态行为:
  • 在C#/Java中,对象始终存在于堆上并在垃圾回收中进行处理,而在C ++中,您可以在静态存储、堆栈或堆(标准术语中的“自由存储”)中拥有对象。您必须清理从堆中分配的东西(new / delete)。用于处理这些内容的宝贵技术是{{link1:RAII}}。
  • 在C++中,继承/多态仅通过指针或引用工作。
还有许多其他问题,但这些可能是您首先遇到的。

2

2

头文件!你会发现自己会问,“为什么每次都需要写两遍方法声明呢?”


2
你不需要写两次声明和定义,只需要写一次即可。 - anon
1
哎呀,要是那么简单就好了 :D - Hassan Syed
你知道我的意思。是的,我知道模板类只会被写一次,之后那个就会被注释掉。 - stusmith
2
除非你喜欢重复输入同样的内容,否则你只需编写一个声明,然后复制粘贴并删除分号以开始定义。然后,当你需要添加另一个参数时,你必须在两个地方进行操作。我认为这是问题的关键所在。 - Daniel Earwicker

1
最大的区别在于C#的引用语义(对大多数类型)与C++的值语义。这意味着对象被复制的次数比在C#中更频繁,因此重要的是确保对象正确复制。这意味着对于任何具有析构函数的类,都需要实现一个拷贝构造函数operator=

1

指针和内存分配

...我也是一个C#程序员,仍在努力理解C/C++中正确的内存使用方法。


3
有趣的是,对于使用C#和Java的人来说,指针应该是非常自然的。在这些语言中,引用实际上是伪装成指针的,现在必须处理释放内存的问题,而知道你不需要使用“new”关键字创建所有对象会有所不同。 - David Rodríguez - dribeas
对于C#和Java开发者来说,指针并不奇怪,反而是非指针类型才奇怪。在C++中,非指针类型的语义比指针类型更简单明了。 - David Thornley
@David Rodriguez - 我怀疑理解问题始于首次遇到将++运算符应用于指针的情况。确实,可以说这是C和C ++的根本问题-在每个对象旁边都有另一个对象。可能如此。 - Daniel Earwicker
当我说“问题”时,当然是指“对于笨蛋来说的问题”。这样就避免了语言战争! :) - Daniel Earwicker

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接