我想不出任何优势(但请参阅底部对 JasonS 的注释),将一行代码包装为函数或子例程。除非您可以将函数命名为“可读”。但是您也可以评论该行。而且由于在函数中封装一行代码会消耗代码内存、堆栈空间和执行时间,在我看来,这大多会适得其反。在教学情况下?这可能有点道理。但这取决于学生的班级、他们的事先准备、课程和老师。大多数情况下,我认为这不是一个好主意。但这是我的看法。
这给我们带来了底线。几十年来,您的广泛问题领域一直是一个争论的问题,直到今天仍然是一个争论的问题。因此,至少在我阅读您的问题时,在我看来这是一个基于意见的问题(正如您所问的那样。)
如果您要更详细地了解情况并仔细描述您作为主要目标的目标,它可以不再像现在这样基于意见。您对测量工具的定义越好,答案可能就越客观。
从广义上讲,您希望对任何编码执行以下操作。(对于下文,我将假设我们正在比较所有实现目标的不同方法。显然,任何未能执行所需任务的代码都比成功的代码更糟糕,无论它是如何编写的。)
- 与您的方法保持一致,以便另一个阅读您的代码的人可以了解您如何处理编码过程。不一致可能是最糟糕的犯罪。它不仅让其他人感到困难,而且让自己在多年后重新回到代码中变得困难。
- 在可能的范围内,尝试安排一些事情,以便可以在不考虑顺序的情况下执行各种功能部分的初始化。在需要排序的情况下,如果是由于两个高度相关的子函数的紧密耦合,则考虑对两者进行一次初始化,以便可以重新排序而不会造成损害。如果这不可能,则记录初始化订购要求。
- 如果可能,将知识封装在一个地方。常量不应在代码中到处重复。求解某个变量的方程应该只存在于一处。等等。如果您发现自己复制并粘贴了一组在不同位置执行某些所需行为的行,请考虑一种在一个地方捕获该知识并在需要时使用它的方法。例如,如果您有一个必须以特定方式行走的树结构,请不要在需要遍历树节点的每个地方复制树遍历代码。相反,在一个地方捕获 tree-walking 方法并使用它。这样,如果树发生变化,行走方法发生变化,您只需要担心一个地方,所有其余代码都“正常工作”。
- 如果您将所有例程展开到一张巨大的平面纸上,并在其他例程调用它们时用箭头连接它们,您将看到在任何应用程序中都会有包含大量箭头的例程“集群”他们之间,但只有几支箭之外的组。因此,紧密耦合的例程之间会有自然的边界,而其他紧密耦合的例程组之间会有松耦合的连接。使用这一事实将您的代码组织成模块。这将大大限制代码的明显复杂性。
以上对于所有编码都是正确的。我没有讨论参数、局部或静态全局变量等的使用。原因是对于嵌入式编程,应用程序空间通常会设置极端且非常重要的新约束,如果不讨论每个嵌入式应用程序就不可能讨论所有这些约束。无论如何,这不会发生在这里。
这些约束可能是其中的任何一个(以及更多):
- 严重的成本限制要求极其原始的 MCU 具有极小的 RAM 和几乎没有 I/O 引脚数。对于这些,全新的规则集适用。例如,您可能不得不用汇编代码编写,因为没有太多代码空间。您可能必须只使用静态变量,因为使用局部变量过于昂贵且耗时。您可能必须避免过度使用子程序,因为(例如,某些 Microchip PIC 部件)只有 4 个硬件寄存器用于存储子程序返回地址。所以你可能不得不大幅“扁平化”你的代码。等等。
- 严重的功率限制需要精心编写的代码来启动和关闭大部分 MCU,并且在全速运行时对代码的执行时间施加了严格的限制。同样,这有时可能需要一些汇编代码。
- 严格的时序要求。例如,有时我必须确保开漏 0 的传输必须与 1 的传输所花费的周期数完全相同。并且还必须执行同一行的采样与这个时间有一个精确的相对相位。这意味着这里不能使用 C。做出这种保证的唯一可能方法是仔细制作汇编代码。(即便如此,并非总是适用于所有 ALU 设计。)
等等。(生命攸关的医疗仪器的接线代码也有自己的整个世界。)
这里的结果是嵌入式编码通常不是免费的,你可以像在工作站上一样编码。各种非常困难的约束通常有严重的竞争原因。这些可能会强烈反对更传统的和普通的答案。
关于可读性,我发现如果代码以我可以在阅读时学习的一致方式编写,则代码是可读的。在没有故意混淆代码的情况下。真的不需要太多了。
可读的代码可以非常有效,它可以满足我已经提到的所有上述要求。最重要的是,当您编写代码时,您完全了解您编写的每一行代码在汇编或机器级别产生的内容。C++ 在这里给程序员带来了沉重的负担,因为在许多情况下,相同的 C++ 代码片段实际上会生成不同的机器代码片段,这些片段具有截然不同的性能。但是,通常 C 主要是一种“所见即所得”的语言。所以在这方面更安全。
根据 JasonS 编辑:
我从 1978 年开始使用 C,从 1987 年左右开始使用 C++,我在大型机、小型机和(主要是)嵌入式应用程序中都拥有丰富的使用经验。
Jason 提出了关于使用“内联”作为修饰符的评论。(在我看来,这是一种相对“新”的功能,因为在我使用 C 和 C++ 的半生或更长时间里,它根本就不存在。)使用内联函数实际上可以进行这样的调用(即使是一行代码)相当实用。在可能的情况下,它比使用宏要好得多,因为编译器可以应用类型。
但也有局限性。首先是你不能依赖编译器来“接受提示”。它可能会,也可能不会。并且有充分的理由不接受暗示。(举个明显的例子,如果取函数的地址,这需要函数的实例化,并且使用地址进行调用将......需要调用。代码不能内联。)有还有其他原因。编译器可能有各种各样的标准来判断如何处理提示。作为一个程序员,这意味着你必须花一些时间了解编译器的这方面,否则您可能会根据有缺陷的想法做出决定。因此,它给代码的编写者和任何读者以及任何计划将代码移植到其他编译器的人都增加了负担。
此外,C 和 C++ 编译器支持单独编译。这意味着他们可以编译一段 C 或 C++ 代码,而无需为项目编译任何其他相关代码。为了内联代码,假设编译器可能会选择这样做,它不仅必须具有“范围内”的声明,而且还必须具有定义。通常,如果他们使用“内联”,程序员会努力确保是这种情况。但是很容易出现错误。
一般来说,虽然我也在我认为合适的地方使用内联,但我倾向于假设我不能依赖它。如果性能是一项重要要求,并且我认为 OP 已经清楚地写道,当他们采用更“功能性”的路线时,性能会受到重大影响,那么我当然会选择避免依赖内联作为编码实践和而是遵循稍微不同但完全一致的代码编写模式。
关于单独编译步骤的“内联”和“范围内”的定义的最后说明。在链接阶段执行工作是可能的(并不总是可靠的)。当且仅当 C/C++ 编译器将足够的细节嵌入目标文件以允许链接器对“内联”请求进行操作时,才会发生这种情况。我个人还没有体验过支持此功能的链接器系统(Microsoft 之外的)。但它可能会发生。同样,是否应该依赖它取决于具体情况。但我通常认为这并没有被铲到链接器上,除非我基于充分的证据知道其他情况。如果我确实依赖它,它将被记录在显眼的位置。
C++
对于那些感兴趣的人,这里有一个例子说明为什么我在编写嵌入式应用程序时对 C++ 保持相当谨慎,尽管它现在已经可用。我将抛出一些我认为所有嵌入式 C++ 程序员都需要了解的术语:
- 部分模板特化
- 虚拟表
- 虚拟基础对象
- 激活框架
- 激活框架展开
- 在构造函数中使用智能指针,以及为什么
- 返回值优化
这只是一个简短的清单。如果您还不了解这些术语的所有内容以及我列出它们的原因(还有更多我没有在此处列出),那么我建议不要将 C++ 用于嵌入式工作,除非它不是项目的选项.
让我们快速浏览一下 C++ 异常语义,了解一下。
当 C++ 编译器完全不知道在单独的编译单元 \$B\$ 中可能需要什么样的异常处理时,它必须为编译单元 \$A\$ 生成正确的代码,在不同的时间单独编译。
以这个代码序列为例,它是某个编译单元 \$A\$ 中某个函数的一部分:
.
.
foo ();
String s;
foo ();
.
.
出于讨论目的,编译单元 \$A\$ 不在其源代码的任何地方使用“try..catch”。它也不使用“投掷”。事实上,假设它不使用任何 C 编译器无法编译的源代码,除了它使用 C++ 库支持并且可以处理像 String 这样的对象。这段代码甚至可能是一个 C 源代码文件,它稍作修改以利用一些 C++ 功能,例如 String 类。
此外,假设 foo() 是位于编译单元 \$B\$ 中的外部过程,并且编译器有它的声明,但不知道它的定义。
如果 foo() 抛出异常,C++ 编译器会看到对 foo() 的第一次调用,并且可以只允许正常的激活帧展开发生。换句话说,C++ 编译器知道此时不需要额外的代码来支持异常处理中涉及的帧展开过程。
但是一旦 String s 被创建,C++ 编译器就知道必须在允许展开帧之前正确地销毁它,如果以后发生异常的话。所以对 foo() 的第二次调用在语义上与第一次不同。如果对 foo() 的第二次调用抛出异常(它可能会或可能不会),编译器必须在让通常的帧展开发生之前放置旨在处理 String 的破坏的代码。这与第一次调用 foo() 所需的代码不同。
(可以在 C++ 中添加额外的修饰来帮助限制这个问题。但事实是,使用 C++ 的程序员必须更加清楚他们编写的每一行代码的含义。)
与 C 的 malloc 不同,C++ 的 new 在无法执行原始内存分配时使用异常来发出信号。“dynamic_cast”也是如此。(参见 Stroustrup 的第 3 版,C++ 编程语言,第 384 和 385 页了解 C++ 中的标准异常。)编译器可能允许禁用此行为。但总的来说,由于生成的代码中正确形成的异常处理序言和尾声,您会产生一些开销,即使异常实际上没有发生,即使正在编译的函数实际上没有任何异常处理块。(Stroustrup 曾公开对此表示遗憾。)
如果没有部分模板专业化(并非所有 C++ 编译器都支持它),模板的使用会给嵌入式编程带来灾难。没有它,代码泛滥是一个严重的风险,可能会在闪存中杀死一个小内存嵌入式项目。
当 C++ 函数返回一个对象时,会创建和销毁一个未命名的编译器临时对象。如果在 return 语句中使用对象构造函数而不是本地对象,则某些 C++ 编译器可以提供高效的代码,从而减少了一个对象的构造和销毁需求。但并不是每个编译器都这样做,许多 C++ 程序员甚至没有意识到这种“返回值优化”。
提供具有单个参数类型的对象构造函数可能允许 C++ 编译器以程序员完全意想不到的方式找到两种类型之间的转换路径。这种“聪明”的行为不是 C 语言的一部分。
指定基类型的 catch 子句将“切片”抛出的派生对象,因为抛出的对象是使用 catch 子句的“静态类型”而不是对象的“动态类型”复制的。异常痛苦的常见来源(当您觉得您甚至可以在嵌入式代码中承受异常时。)
C++ 编译器可以自动为您生成构造函数、析构函数、复制构造函数和赋值运算符,但会产生意想不到的结果。了解这些细节需要时间。
将派生对象的数组传递给接受基础对象数组的函数,很少会产生编译器警告,但几乎总是会产生不正确的行为。
由于 C++ 在对象构造函数中发生异常时不会调用部分构造对象的析构函数,因此在构造函数中处理异常通常需要“智能指针”,以保证在构造函数中构造的片段在发生异常时被正确销毁. (参见 Stroustrup,第 367 和 368 页。)这是用 C++ 编写好的类时的常见问题,但在 C 中当然可以避免,因为 C 没有内置的构造和破坏语义。编写适当的代码来处理构造对象中的子对象意味着编写必须处理 C++ 中这种独特语义问题的代码;换句话说,“围绕”C++ 语义行为进行写作。
C++ 可以复制传递给对象参数的对象。例如,在以下片段中,调用“rA(x);” 可能会导致 C++ 编译器调用参数 p 的构造函数,以便随后调用复制构造函数将对象 x 传递给参数 p,然后调用函数 rA 的返回对象(一个未命名的临时对象)的另一个构造函数,这当然是从参数 p 复制。更糟糕的是,如果 A 类有自己的需要构造的对象,这可能会造成灾难性的影响。(AC 程序员会避免大部分这种垃圾,手工优化,因为 C 程序员没有这么方便的语法,必须一次表达所有细节。)
class A {...};
A rA (A p) { return p; }
// .....
{ A x; rA(x); }
最后,给 C 程序员的简短说明。longjmp() 在 C++ 中没有可移植的行为。(一些 C 程序员将此用作一种“异常”机制。)一些 C++ 编译器实际上会尝试设置一些东西以在采用 longjmp 时进行清理,但这种行为在 C++ 中是不可移植的。如果编译器确实清理了构造的对象,则它是不可移植的。如果编译器不清理它们,那么如果代码由于 longjmp 而离开构造对象的范围并且行为无效,则对象不会被破坏。(如果在 foo() 中使用 longjmp 不会留下作用域,那么行为可能没问题。)C 嵌入式程序员不经常使用它,但他们应该在使用它们之前让自己意识到这些问题。