C++ v.s. C#

1,352 阅读12分钟

背景

很久以前我曾经给GacUI写过一个工具,功能是把大量的h和cpp文件打包成若干个大的文件,譬如说GacUI最终会输出

  • GacUI.h / GacUI.cpp
  • GacUIWindows.h / GacUIWindows.cpp
  • GacUIReflection.h / GacUIReflection.cpp
  • GacUICompiler.h / GacUICompiler.cpp

就是这个工具做的。因为我懒得在Ubuntu下面折腾dotnet core(其实搞过,也很简单,但是不想加入我的一键装环境脚本里面(逃),于是原本在Visual Studio 2010下面用C#写的CodePack,我大概花了加起来一整天的时间,用C++重写了一遍,就可以在Ubuntu下面用clang++来编译了。

重写前:CodePack (C#)

重写后:CodePack (C++)

写完之后很有感触,于是今天我来比较一下C++和C#在处理这些乱七八糟的事情的时候的区别。

首先大家需要对这个工具有一个基本的概念。如何把一大堆文件打包成几组文件呢?大概的想法就是:

  • 把h文件一个一个拼起来,cpp文件一个一个拼起来
  • 拼h文件的时候要注意顺序,要把被别人include的那些排在前面
  • 拼完之后的大文件也要互相include

里面涉及到一个拓扑排序的算法。大家不要学我这么写,因为我是出于实践输入的文件个数只有几百个的现状,从而用最快的速度随便弄了一个拓扑排序,搞出来有n³logn那么复杂。我猜实际上是不应该的,因为这个复杂度看起来有点大。下面是最耗复杂度的一个片段:

n->	FOREACH_INDEXER(T, category, index, unsorted)
	{
logn->		if (!deps.Keys().Contains(category))
		{
n->			sorted->Add(category);
n->			unsorted.RemoveAt(index);
n->			for (vint i = deps.Count() - 1; i >= 0; i--)
			{
nlogn->				deps.Remove(deps.Keys()[i], category);
			}
			break;
		}
	}
Worst Case: n * (logn + n + n + n * nlogn) = n³logn,不过平均情况要好得多

在实践中,Release编译的时候跑这段函数,就算输入几百上千个文件和它们的include目标列表,排序的时间也是快到看不见,所以就不改了(逃

现在就来开始一一比对。虽然我并没有使用STL,但是我用到的自己的字符串、正则表达式、容器、foreach和Linq(C++即将加入标准的ranges有几乎一致的功能),STL里面都有。所以看起来很公平。

1:扫描磁盘文件

C#的写法很简单,因为他已经内置了一个搜索文件的函数:

     static string[] GetCppFiles(string folder)
        {
            return Directory
                .GetFiles(folder, "*.cpp", SearchOption.AllDirectories)
                .Select(s => s.ToUpper())
                .ToArray()
                ;
        }
        static string[] GetHeaderFiles(string folder)
        {
            return Directory
                .GetFiles(folder, "*.h", SearchOption.AllDirectories)
                .Select(s => s.ToUpper())
                .ToArray()
                ;
        }

而我只有遍历目录的函数,所以要用一个递归。在这里要提出的是,Windows API提供了C#的那个功能,所以你想直接调用也可以,我只是突然发现有这个需求之后,懒得封装,等以后有更多的需要再写进去。

LazyList<FilePath> SearchFiles(const Folder& folder, const WString& extension)
{
	auto files = MakePtr<List<File>>();
	auto folders = MakePtr<List<Folder>>();
	folder.GetFiles(*files.Obj());
	folder.GetFolders(*folders.Obj());

	return LazyList<File>(files)
		.Select([](const File& file) { return file.GetFilePath(); })
		.Where([=](const FilePath& path) { return INVLOC.EndsWith(path.GetName(), extension, Locale::IgnoreCase); })
		.Concat(
			LazyList<Folder>(folders)
			.SelectMany([=](const Folder& folder) { return SearchFiles(folder, extension); })
			);
}

LazyList<FilePath> GetCppFiles(const FilePath& folder)
{
	return SearchFiles(folder, L".cpp");
}

LazyList<FilePath> GetHeaderFiles(const FilePath& folder)
{
	return SearchFiles(folder, L".h");
}

2:对文件进行分类

分类的意思,其实就是按预先设置好的pattern把文件分组。在这里有必要展示一下配置文件:

<?xml version="1.0" encoding="utf-8" ?>
<codegen>
  <folders>
    <folder path="..\Source" />
    <folder path="..\Import" />
  </folders>
  <categories>
    <category name="vlpp" pattern="\Import\Vlpp."/>
    <category name="wfruntime" pattern="\Import\VlppWorkflow."/>
    <category name="wfcompiler" pattern="\Import\VlppWorkflowCompiler."/>
    <category name="gacui" pattern="\Source\">
      <except pattern="\Windows\" />
      <except pattern="\WindowsDirect2D\" />
      <except pattern="\WindowsGDI\" />
      <except pattern="\Reflection\" />
      <except pattern="\Compiler\" />
    </category>
    <category name="windows" pattern="\Source\GraphicsElement\WindowsDirect2D\" />
    <category name="windows" pattern="\Source\GraphicsElement\WindowsGDI\" />
    <category name="windows" pattern="\Source\NativeWindow\Windows\" />
    <category name="reflection" pattern="\Source\Reflection\" />
    <category name="compiler" pattern="\Source\Compiler\" />
  </categories>
  <output path=".">
    <codepair category="vlpp" filename="Vlpp" generate="false"/>
    <codepair category="wfruntime" filename="VlppWorkflow" generate="false"/>
    <codepair category="wfcompiler" filename="VlppWorkflowCompiler" generate="false"/>
    <codepair category="gacui" filename="GacUI" generate="true"/>
    <codepair category="windows" filename="GacUIWindows" generate="true"/>
    <codepair category="reflection" filename="GacUIReflection" generate="true"/>
    <codepair category="compiler" filename="GacUICompiler" generate="true"/>
  </output>
</codegen>

<categories>下面的就是文件的分类,基本上是说哪些文件要被分成一组。<output>规定了每一组最终输出的文件名。首先看C#的代码。这个函数虽然比较长,但是内容是很简单的。上一步我们已经获取了所有的文件,那么现在即使匹配所有的//category@pattern和//category/except@pattern,把他们筛选出来,最后把重复的文件干掉。

     static Dictionary<string, string[]> CategorizeCodeFiles(XDocument config, string[] files)
        {
            Dictionary<string, string[]> categorizedFiles = new Dictionary<string, string[]>();
            foreach (var e in config.Root.Element("categories").Elements("category"))
            {
                string name = e.Attribute("name").Value;
                string pattern = e.Attribute("pattern").Value.ToUpper();
                string[] exceptions = e.Elements("except").Select(x => x.Attribute("pattern").Value.ToUpper()).ToArray();
                string[] filteredFiles = files
                        .Where(f =>
                            {
                                string path = f.ToUpper();
                                return path.Contains(pattern) && exceptions.All(ex => !path.Contains(ex));
                            })
                        .ToArray();
                string[] previousFiles = null;
                if (categorizedFiles.TryGetValue(name, out previousFiles))
                {
                    filteredFiles = filteredFiles.Concat(previousFiles).ToArray();
                    categorizedFiles.Remove(name);
                }
                categorizedFiles.Add(name, filteredFiles);
            }
            foreach (var a in categorizedFiles.Keys)
            {
                foreach (var b in categorizedFiles.Keys)
                {
                    if (a != b)
                    {
                        if (categorizedFiles[a].Intersect(categorizedFiles[b]).Count() != 0)
                        {
                            throw new ArgumentException();
                        }
                    }
                }
            }
            return categorizedFiles;
        }

最后一步只是防御性的,如果最终发现同一个文件同时出现在不同的分组里面,就崩溃。我也懒得报错,崩溃了就直接debug这个程序,马上就看见了(逃

下面是C++的代码。可以看到基本上是没什么区别的。但是从现在开始,会发现C++写lambda表达式比C#要啰嗦。当然lambda表达式的参数类型本是不应该写出来的(要用auto替换),只是我的Linq比较旧,当年写的时候C++的lambda表达式还不能是模板函数,所以现在就不能兼容这个新feature了。找个时候把他改了。

至于具体的原因,因为在Linq里面,对于任何的lambda表达式F,我都用

取函数指针返回值的模板类<decltype(&F::operator())>

来获取返回值,从而生成Linq函数的结果类型。如果F的operator()是一个模板函数的话,显然这样写是不行的,以后还是要直接decltype(declval<F>()(参数))来获取。

void CategorizeCodeFiles(Ptr<XmlDocument> config, LazyList<FilePath> files, Group<WString, FilePath>& categorizedFiles)
{
	FOREACH(Ptr<XmlElement>, e, XmlGetElements(XmlGetElement(config->rootElement, L"categories"), L"category"))
	{
		auto name = XmlGetAttribute(e, L"name")->value.value;
		auto pattern = wupper(XmlGetAttribute(e, L"pattern")->value.value);

		List<WString> exceptions;
		CopyFrom(
			exceptions,
			XmlGetElements(e,L"except")
				.Select([](const Ptr<XmlElement> x)
				{
					return XmlGetAttribute(x, L"pattern")->value.value;
				})
			);

		List<FilePath> filterFiles;
		CopyFrom(
			filterFiles,
			From(files).Where([&](const FilePath& f)
				{
					auto path = f.GetFullPath();
					return INVLOC.FindFirst(path, pattern, Locale::IgnoreCase).key != -1
						&& From(exceptions).All([&](const WString& ex)
						{
							return INVLOC.FindFirst(path, ex, Locale::IgnoreCase).key == -1;
						});
				})
			);

		FOREACH(FilePath, file, filterFiles)
		{
			if (!categorizedFiles.Contains(name, file))
			{
				categorizedFiles.Add(name, file);
			}
		}
	}

	FOREACH(WString, a, categorizedFiles.Keys())
	{
		FOREACH(WString, b, categorizedFiles.Keys())
		{
			if (a != b)
			{
				const auto& as = categorizedFiles.Get(a);
				const auto& bs = categorizedFiles.Get(b);
				CHECK_ERROR(!From(as).Intersect(bs).IsEmpty(), L"A file should not appear in multiple categories.");
			}
		}
	}
}

3:递归枚举这个文件直接或者间接#include的所有文件

从这里开始,C#和C++的程序会有一点点区别。上面我提到说把所有头文件拼接起来的时候是要注意顺序的,而这个顺序就是GetIncludedFiles所要做的。然而在C++里面,因为我的容器跟C#的容器有一些区别,导致我无法原汁原味地复制这份代码,于是就把(4)的拓扑排序函数改成了模板函数,最终在(6)的Combine函数里面排序。

因此,在C#的版本里面,排序是GetIncludedFiles做的,而C++的版本则是在Combine函数里面做的。

     static Dictionary<string, string[]> ScannedFiles = new Dictionary<string, string[]>();
        static Regex IncludeRegex = new Regex(@"^\s*\#include\s*""(?<path>[^""]+)""\s*$");
        static Regex IncludeSystemRegex = new Regex(@"^\s*\#include\s*\<(?<path>[^""]+)\>\s*$");

        static string[] GetIncludedFiles(string codeFile)
        {
            codeFile = Path.GetFullPath(codeFile).ToUpper();
            string[] result = null;
            if (!ScannedFiles.TryGetValue(codeFile, out result))
            {
                List<string> directIncludeFiles = new List<string>();
                foreach (var line in File.ReadAllLines(codeFile))
                {
                    Match match = IncludeRegex.Match(line);
                    if (match.Success)
                    {
                        string path = match.Groups["path"].Value;
                        path = Path.GetFullPath(Path.GetDirectoryName(codeFile) + @"\" + path).ToUpper();
                        if (!directIncludeFiles.Contains(path))
                        {
                            directIncludeFiles.Add(path);
                        }
                    }
                }

                for (int i = directIncludeFiles.Count - 1; i >= 0; i--)
                {
                    directIncludeFiles.InsertRange(i, GetIncludedFiles(directIncludeFiles[i]));
                }
                result = directIncludeFiles.Distinct().ToArray();
                ScannedFiles.Add(codeFile, result);
            }
            return result;
        }

GetIncludedFiles的内容也很简单,就是把这个文件每一行都用正则表达式找到#include "",然后打开被#include的文件继续做,直到做完为止。结果会被缓存到scannedFiles变量里面,不会重复使用。C#的版本把后发现的文件放在前面,因为很显然,如果a.h#include了b.h,那么肯定要先打开b才能发现a。但是我写的C++容器没有关键的InsertRange函数,于是我只能大段大段地往后添加。但是你说添加完Reverse一下嘛,他们是不等价的。所以C++版本我就把保证顺序的事情挪到了后面。

Dictionary<FilePath, LazyList<FilePath>> scannedFiles;
Regex regexInclude(LR"/(^\s*#include\s*"(<path>[^"]+)"\s*$)/");
Regex regexSystemInclude(LR"/(^\s*#include\s*<(<path>[^"]+)>\s*$)/");

LazyList<FilePath> GetIncludedFiles(const FilePath& codeFile)
{
	{
		vint index = scannedFiles.Keys().IndexOf(codeFile);
		if (index != -1)
		{
			return scannedFiles.Values()[index];
		}
	}

	List<FilePath> includes;
	StringReader reader(ReadFile(codeFile));
	while (!reader.IsEnd())
	{
		auto line = reader.ReadLine();
		if (auto match = regexInclude.MatchHead(line))
		{
			auto path = codeFile.GetFolder() / match->Groups()[L"path"][0].Value();
			if (!includes.Contains(path))
			{
				includes.Add(path);
			}
		}
	}

	auto result = MakePtr<List<FilePath>>();
	CopyFrom(
		*result.Obj(),
		From(includes)
			.Concat(From(includes).SelectMany(GetIncludedFiles))
			.Distinct()
		);

	scannedFiles.Add(codeFile, result);
	return result;
}

可以看出,两个版本的代码仍然是相当接近的。可见处理字符串的时候,其实C++和C#的区别,也就只有语法区别。

4:拓扑排序

上面已经贴过了,故省略。如果大家对比一下C++和C#的版本,会发现他们主要的区别是:

  • C++推崇值类型容器,于是Linq就不可能有C#的ToArray、ToList和ToDictionary这些东西,也更不会有Dictionary<string, string[]>了。
  • 后面这个一对多的容器我有Group<WString, WString>来代替,但是Group不能表达key存在但是value不存在的情况。

因此这两个主要区别导致了两个版本的代码在逻辑上稍微有点差异。

5:最长公共前缀

在查找最长公共前缀的时候,C#的版本算的是字符串,而C++算的是路径,导致了C#的版本如果每一个文件不仅文件夹一样而且文件名的前缀也一样的话,文件名还会被砍掉一部分。不过输出的文件名只存在于注释里,错就错了无所谓,所以一直没有改。我借着这次重写的机会把这个bug修了。

C#:

     static string GetLongestCommonPrefix(string[] strings)
        {
            if (strings.Length == 0) return "";
            int shortestLength = strings.Select(s => s.Length).Min();
            return Enumerable.Range(0, shortestLength + 1)
                .Reverse()
                .Select(i => strings[0].Substring(0, i))
                .Where(s => strings.Skip(1).All(t => t.StartsWith(s)))
                .First();
        }

C++:

FilePath GetCommonFolder(const List<FilePath>& paths)
{
	auto folder = paths[0].GetFolder();
	while (true)
	{
		if (From(paths).All([&](const FilePath& path)
			{
				return INVLOC.StartsWith(path.GetFullPath(), folder.GetFullPath() + WString(folder.Delimiter), Locale::IgnoreCase);
			}))
		{
			return folder;
		}
		folder = folder.GetFolder();
	}
	CHECK_FAIL(L"Cannot process files across multiple drives.");
}

6:把一堆文件粘起来

这个函数太长我就不贴了,总的来说C#和C++两个版本的写法都是一摸一样的,除了C++的版本因为上面提到过的原因,要调用一下SortDependencies函数。

7:Main函数

也是太长所以我只贴一个显著有区别的片段,这个片段是用来计算分组之间的依赖关系的,最后输出的结果就是GacUI.h会去#include "Vlpp.h"。先来看C#的版本:

         var categoryDependencies = categorizedCppFiles
                .Keys
                .Select(k =>
                {
                    var headerFiles = categorizedCppFiles[k]
                        .SelectMany(GetIncludedFiles)
                        .Distinct()
                        .ToArray();
                    var keys = categorizedHeaderFiles
                        .Where(p => p.Value.Any(h => headerFiles.Contains(h)))
                        .Select(p => p.Key)
                        .Except(new string[] { k })
                        .ToArray();
                    return Tuple.Create(k, keys);
                })
                .ToDictionary(t => t.Item1, t => t.Item2);

多清爽呀!把每一个category的所有cpp文件所直接或者间接include的h文件找出来,然后反向查找它们的category(懒得做索引所以暴力算)。然后看C++的版本:

	Group<WString, WString> categoryDepedencies;
	CopyFrom(
		categoryDepedencies,
		From(categorizedCppFiles.Keys())
			.SelectMany([&](const WString& key)
			{
				SortedList<FilePath> headerFiles;
				CopyFrom(
					headerFiles,
					From(categorizedCppFiles[key])
						.SelectMany(GetIncludedFiles)
						.Distinct()
					);

				auto keys = MakePtr<SortedList<WString>>();
				CopyFrom(
					*keys.Obj(),
					From(categorizedHeaderFiles.Keys())
						.Where([&](const WString& key)
						{
							return From(categorizedHeaderFiles[key])
								.Any([&](const FilePath& h)
								{
									return headerFiles.Contains(h);
								});
						})
					);
				keys->Remove(key);

				return LazyList<WString>(keys).Select([=](const WString& k)->Pair<WString, WString>{ return {key,k}; });
			})
		);

又臭又长(逃。不过实际上他们是没有区别的,这完全都怪C++的lambda表达式语法太罗嗦,我按照我的喜好排版,只好把一行分成好多行。

结论

总的来说,只要C++的lambda表达式能跟C#一样写的话,那这两门语言的区别也就在于GC和shared_ptr了。在做很多不是极端在意性能的事情的时候,都可以一阵胡写,开发效率应该是相当接近的。

大家还会注意到,C++里面我有时候直接写List<int>,有时候MakePtr<List<int>>,其实主要的区别在于,lambda表达式如果在退出这个函数之后还要被执行,那引用一个List<int>的局部变量就要跪(因为已经被释放了)。这个时候我用智能指针把它拿住,就没有这个问题了。

同样是写正则表达式,C++的LR"FuckShitBitch(abcd)FuckShitBitch"比C#的@"abcd",要好用一万倍!

如果你做的事情非常在乎性能,那你可能需要考虑一下,一个项目在经过profiling的检验之后,把一小部分C#代码用C++来写。C++可以通过付出把代码写得超级丑陋并且无法用低薪程序员维护的代价,来换取无敌的性能,这一点C#就不行,当然这也不是C#设计出来的目的。