2.4 C语言入职例程一:空心菱形输出

2.4.1 工作闭环和边界判断

为了帮助新人快速上手,我将自己当初学习Pascal时的第一个例程借了过来。我给新人布置的第一项任务是:“快速复习阅读《C程序设计语言》前4章,然后写一控制台程序:已知内层和外层菱形的高度,输出一空心菱形”。

大家是否会感觉这是一个很简单的例程呢?实际上这是我精心挑选设计出来的一个例程,因为其中有很多的坑。这个例子本身并不复杂,一般通过公司层层筛选招聘进来的学生,总是可以搞定的,但想不踩坑是不可能的。

多年职场经历告诉我一个很高效的团队工作技能:让工作闭环。遗憾的是,现实世界中,经常给新人安排一项工作后,如果你不主动问,经常就会没有任何反馈了。经常让人感叹,工作做完了,就不能回复一下吗?这个例程中我故意设计了一个陷阱,让新人去体会这种不反馈的后果,然后在顺势培养闭环的这种工作习惯。

已知内层和外层菱形的高度,输出一空心菱形,这个例程中菱形的高度并没有被清晰准确的定义。按照常规理解,习惯性将整个整个菱形高度作为高度,此时需要较复杂的边界判断逻辑,如下:

  1. 内层高度>0;
  2. 内层高度<屏幕边界;
  3. 内层高度为奇数;
  4. 外层高度>0;
  5. 外层高度<屏幕边界;
  6. 外层高度为奇数;
  7. 外层高度>内层高度。

新人的第一个提交版本一般很难做出完善的判断,甚至有很多人都意识不到需要进行边界判断,此时,在我的刻意错误输入下,程序就会出现各种各样异常,如下图所示:
在这里插入图片描述
又或干脆是:
在这里插入图片描述

相比较网络或数据库程序,大多数工控产品代码规模并不大,但对程序质量要求却比较严格。因此,如何写出高效简洁的边界判断语句,是一个嵌入式程序员的基本功。回到该例程,如何简洁且有效的对菱形高度进行判断,成为写好该例程的首个关键点。

在该例程的边界判断有个技巧,如果能约定菱形的高度为菱形上三角形的高度,边界判断就简洁很多(此时程序实现也会简单很多),如下:

  1. 菱形内层高度大于等于0;
  2. 菱形外层高度小于约定值(屏幕边界);
  3. 菱形内层高度小于菱形外层高度。

概念的重定义刚开始可能会让某些新人有不适感。概念是为了目标而服务的,不然就需要增加两条奇数判断逻辑,程序简洁性也会打折扣了。在嵌入式软件中,为了简化程序构建各种各样的概念是经常的事,如为了程序架构清晰,基于小容量eeprom也可以构建文件系统,但又不同于我们传统理解的文件系统。

重新定义菱形高度这个概念后,该例程就需要重新描述了。此时,我一般会让新人用自己的话重新描述该例程,遗憾的是现实世界中很多人做不好,可能源于平时缺乏相应训练吧。该处,希望大家稍微停下来想一想,试着用自己的话清晰描述这个例程。

一个团队协作时,会存在大量的交流,而交流过程中,总是会产生或多或少的歧义,而恰恰是这些歧义会导致需求的不明确,会导致大量的返工,甚至会导致项目的失败。因此,我给新人的第一份工作要求就是:执行前,将任务用自己的语言表达出来,和对方确认后再实施。这一点在以后的团队协作中非常的重要,因此我早早的将这一点嵌入到了入职培训中。如该例程在实现之前能多问一句,我或许就会提醒边界判断,就可能少走一些弯路。

经过这么一折腾,空心菱形例程重新描述如下:“写一控制台程序,用户输入内层和外层菱形的高度,输出一个空心菱形。菱形的高度约定为菱形的上三角形的高度,如输入5和3,输出如下”

    *
   ***
  ** **
 **   **
**     **
 **   **
  ** **
   ***
    *

用一个例子描述,比一大堆文字惯用多了,一图顶千言。

2.4.2 代码分节

需求明确后,就会看到各种各样的实现版本。如某人的策略如下:将空心菱形分为上中下三部分,如下图所示:

    *
   ***
---------
  ** **
 **   **
**     **
 **   **
  ** **
--------
   ***
    *

按照这种逻辑,程序主体如下:

for(i=1;i<2*m;i++)
{
    if(i<=m-n) {
        star = 2*i-1;
        empty= m-i;
        while(empty--)
            printf(" ");
        while(star--)
            printf("*");
    }
    else if(m-n<i &&  i< m+n && j< 2*n) {
        if(j <= n && i <= m){
            num_empty = 2*j-1;
            empty = m -i;
        }
        else{
            num_empty = 2*(2*n-1-(j-1))-1;
            empty = i-m;
        }
        num_star = star = m-n;

        while(empty--)
            printf(" ");
        while(star--)
            printf("*");
        while(num_empty--)
            printf(" ");
        while(num_star--)
            printf("*");
        j++;
    } else {
        star = 2*(2*m-1-(i-1))-1;
        empty = (2*m-1-star)/2;
        while(empty--)
            printf(" ");
        while(star--)
            printf("*");
    }
    printf("\n");
}

该段例程是我带的某小伙写出来的,一开始就能写出这样的程序,该小伙还是蛮优秀的。在空心菱形程序输出过程中,一般需要构建一些复杂的表达式,很难不经调试就一次成功。因此,我会顺势询问小伙在程序调试过程中用过哪些技巧?

可能是大学阶段的程序都比较短小,或者是大家互相拷贝,一般都很少进行调试技能训练,或者顶多就是单步执行、设置断点和变量查看等基础手段。实际工作中,随着代码量的增加,调试熟练与否会严重影响到工作效率。因此即使该例程用不到太多的调试技巧,我也会借此机会给新人强调一下调试的重要性,并给大家讲解一些常用的调试技巧,如执行堆栈查看、内存查看(大端和小端模式)、条件断点、计数器、debug输出、反汇编、程序优化下的调试畸变现象等,同时也指出还有很多程序全速运行时的调试策略。通过该处的讲解,新人的调试能力不会一下子提升很多,但眼界会开阔,后续工作中就会有意识的持续训练。限于篇幅,该处提及的各种调试技巧就不展开了,后续如有机会单独成章描述。

让我们回到这段例程代码实现,大家试想一个问题,如果这段程序是你自己写的,一年后你还能记得其逻辑吗,一大堆m和n的表达式,是否会感觉像乱麻。这个问题也暴露了真实世界中一个难以克服的现实:程序代码难于审核、难于维护、难于分享。

我大四勤工助学时,曾参加某日本银行委托的软件外包项目。为了提升代码质量,当时有一个日方品控人员给我们讲代码规范化,一开始就用了一堂课讲解代码分节。遗憾的是我当初年轻气盛,内心还有点敌视日本的情怀,不仅逃课,还经常是左耳朵进右耳朵出。

工作多年后面对程序维护的各种困局,我才突然意识到代码分节的好处,然后先是自己尝试着使用,在然后是整个项目组使用,最后大家都慢慢的体会到其巨大好处后,代码分节成为我的项目团队中最重要的程序规范。此时,我才开始后悔当初自己为何不能稍微多听几堂课。

借助整理这段代码的机会,我会同新人分享代码分节的策略和价值。只有通过代码分节,新人和老人之间才能形成审核闭环,程序才具备维护性,知识也可以传递……。概念讲解完,我会要求大家按照如下结构编写代码:

int main()
{
    /* 输入内外菱形高度,并进行合法判断 */

    /* 循环输出菱形 */
    for (……)
    {
        /* 输出前导空格 */

        /* 输出左边星号 */

        /* 输出中间空格 */

        /* 输出右边星号 */

        /* 输出换行 */
        printf("\n");
    }
    return 0;
}

前文提及,工控程序大多数都不复杂,只要能掌握单循环判断程序结构,就能应付初期简单工作。学习这类程序,关键是找到程序和迭代子之间的关系。为了帮助新人加深理解,我一般会举三角形的例子,如已知三角形的高度,输出一三角形如下:

    *
   ***
  *****
 *******
*********

输出分为两部分,设三角形高度为h,第一部分为前导空格,数量f(x)=h-x-1,第二部分为三角形部分,数量f(x)=2x+1。一旦理清了这样的关系,程序就变得非常简单了,如下示例:

for (i = 0; i < h; i++)
{
  //输出前导空格
  y = h-i-1;
  while (y--)
  printf(" ");
  
  //输出三角形
  y = 2*i+1;
  while (j--)
    printf("*");
  
  //输出换行
  printf("\n");
}

虽然空心菱形稍微复杂一点,但在这样的引导之下,大部分人都可以顺利的完成合格的程序,示例如下:

#include <stdio.h>

/* 菱形最大高度 */
#define MAX_HEIGHT 16

/* 主程序 */
int main()
{
    int n, nRow;
    int nIn, nOut;
    int nCount1, nCount2, nCount3;

    /* 输入内外菱形高度,并进行合法判断 */
    for (;;)
    {
        printf("输入内外菱形高度(最大%d行):外菱形高度,内菱形高度:\n", MAX_HEIGHT-1);
        scanf("%d,%d", &nOut, &nIn);
        if (nIn < nOut && nOut < MAX_HEIGHT && nIn >= 0)
            break;
        printf("输入不合法,请重新输入:\n\n");
    }

    /* 循环输出菱形 */
    nIn = nOut - nIn;        /* 调整为菱形内外差值 */
    for (nRow = -nOut+1; nRow < nOut; nRow++)
    {
        /* 输出前导空格 */
        nCount1 = nRow >= 0 ? nRow : -nRow;    /* 取绝对值 */
        for (n = 0; n < nCount1; n++)
            printf(" ");

        /* 输出左边星号 */
        nCount1 = nOut - nCount1;            	/* 外三角部分,后续迭代使用 */
        nCount2 =  nCount1 - nIn;  				/* 内三角部分,后续迭代使用 */
		if (nCount2 < 0)
			nCount2 = 0;
        nCount3 = nCount1 - nCount2;         	/* 内外之差为实际需要输出 */
        for (n = 0; n < nCount3; n++)
            printf("*");

        /* 输出中间空格 */
        nCount3 = 2 * nCount2 - 1;           	/* 由三角形拓展为菱形 */
        for (n = 0; n < nCount3; n++)
            printf(" ");

        /* 输出右边星号 */
        nCount1--;                           		/* 外三角部分 */
        nCount2 = nCount1 - nIn;  				/* 内三角部分 */
		if (nCount2 < 0)
			nCount2 = 0;
        nCount3 = nCount1 - nCount2;         	/* 内外之差为实际输出 */
        for (n = 0; n < nCount3; n++)
            printf("*");

        /* 输出换行 */
        printf("\n");
    }
    return 0;
}

有没有发现,这段代码虽然相比前面的代码臃肿了很多,但结构清晰了许多。经过多年的迭代后,我们项目组约定所有节必须有节注释,且以空行间隔。这样,后续如需进行代码审核或其他工作,我们就可以通过节注释快速理解程序整体结构,而不是痛苦的通过阅读代码去猜测。

代码分节,是从个人主义走向团队协作的起点。

2.4.3 权衡的艺术

不知大家是否感受到,通过代码分节,虽然程序结构变得清晰了,但整个代码还是给人一种复杂的感觉。空心菱形程序输出,有一个颇具技巧性的实现策略,如果能将空心菱形放置到坐标系统中,在用一点点解析几何的知识,立马会有焕然一新的感觉。
在这里插入图片描述
设菱形的高度为nOut,外菱形的四条边用表达式表达,如下:

(+x) + (+y) = nOut
(-x) + (+y) = nOut
(+x) + (-y) = nOut
(-x) + (-y) = nOut

合并后为abs(x)+abs(y)=nOut;同理内菱形四条边为abs(x)+abs(y)=nIn;此时整个空心菱形输出程序就简单多了,主体程序如下:

/* 输出空心菱形 */
for (x = -nOut + 1; x < nOut; x++)
{
	for (y = -nOut + 1; y < nOut; y++)
	{
		t = abs(x) + abs(y);
		if (t >= nIn && t < nOut)
			printf("*");
		else
			printf(" ");
	}
	printf("\n");
}

是否有一种很清爽的感觉,我仅记得自己当初看到这个版本实现后,内心有一种被简单的数学美给震撼的感觉。

工控嵌入式产品软件,同常规PC软件最大的一个差异是资源受限。编写工控嵌入式软件时,经常需要追求性能和资源的均衡,有时,可读性、可维护性、cpu计算能力、各类内存、缓冲机制、程序空间、可靠性等都会成为了我们反复权衡的因素。鉴于此,大家平时开玩笑,喜欢将我们的工作戏称为针尖上的舞蹈。

为了让新人尽早能体会并意识到嵌入式系统这个特点,此时,我会提出一个问题:“假设printf最终输出到嵌入式硬件设备上,而该设备仅支持批量输入和单个输入,效率相当,分析空心菱形输出的两个版本程序优缺点,以及如何克服?”

设定条件后,大多数人都能发现原先版本的程序输出部分可以优化为批量输出,而后一个版本程序不具备优化空间,因此前一个版本执行效率更高一些。部分人能够想到克服策略,增加中间缓冲区,可以将单个输出优化为批量输出。

借助该例子,我提前给新人灌输了一个观念:嵌入式软件没有简单的最好,只有在约束条件下的最优实现。如前面两个版本程序,考虑我们的假设条件后,第一个版本执行效率高,代码空间大,可读可维护性低。第二个版本执行效率低,代码空间小,可读可维护性高。第二个加缓存版本执行效率高、代码空间小,易维护、但ram资源使用量大。如何选择需要依据实际情况权衡。

该例程比较短小,权衡的意义并不大,但希望在大家心中埋下一粒种子,以后碰到各种约束条件下稀奇古怪的应对招式,就会见怪不怪了。在本书中,你会发现权衡的艺术无处不在。

2.4.4 总结和思考

我工作的第二年,碰到一个我一直很尊敬的职场导师。他有一个工作日记本,仅仅是简单的txt文档格式,但里面密密麻麻的记录着各种调试记录、感悟想法、技术资料、知识归纳等。榜样的力量是无穷的,从此我也开始试着做工作笔记。

刚开始我也用的是txt,但发现随着内容的增加,不好组织,慢慢的换成了ediary(一款写日记的国产小软件),在后来换成了有道云笔记。一晃近二十年过去了,我发现自己在不经意间记录下了厚厚的各种随笔、文摘、工作纪要和头脑风暴,甚至还有生活琐事和喜怒哀乐。闲来无事时翻一翻,瞬间感慨万千,感慨自己竟会因为某些琐事而伤感,也经常会愚蠢到想扇自己耳光的程度。最重要的是,通过工作笔记,我能清晰地看到自己的成长,一开始我会为程序技巧而兴奋,但后来则越发喜欢程序整体架构设计;一开始仅喜欢关注自己本职工作,后来会习惯站在整个团队和产品角度思考……。

为了让整个团队一起成长,从此,我们的项目组形成了一条不成文的规矩,每个人必须做工作笔记,不管形式,不限格式,只要开始记录就好。当然,还需要你整理、思考和提高。

此时,一些人会忍不住问,笔记都是个人的,也没法分享,对团队有什么意义呢?不急,团队有一些策略会逼迫和诱惑着你去做,而这些笔记恰恰是后续知识库的养料。

一个简简单单的空心菱形的例子,新人可能会被我折腾的写了五六个程序版本。经过了马拉松的经历,很多小伙伴都被磨的快没脾气了。不过程序写完后,还有最重要的一件事情要做:“分析自己写的几个版本程序,并将自己的感悟记录下来。”

同很多小伙伴交流,发现大家印象比较深刻的是如下这些版本:

  1. 第一个漏洞百出的版本;
  2. 符合代码分节规则的版本;
  3. 符合项目组规范的标准版本;
  4. 限定条件下的权衡版本。

你自己记忆深刻的是哪几个版本呢?又是因为什么原因让你印象深刻呢?此时,你不妨掩卷沉思片刻,也记录下自己的感悟。

现在让我们一起归纳汇总空心菱形例程中提到的知识点:

  1. 清晰理解需求。最简单的策略是:将需求用自己的语言表达出来,和对方确认后再实施。
  2. 边界判断。大家应该意识到:在嵌入式产品中,最终产品的鲁棒性,很多时候就是表现在这样一点一滴的简单判据上,而写出简洁且有效的的判据,是嵌入式程序员的基本功,平时需要刻意锻炼。
  3. 熟悉单循环判断程序结构。
  4. 基本调试技能的训练,工欲善其事必先利其器,无须多言。
  5. 代码分节。体会代码分节带来的价值。
  6. 在嵌入式编程领域,资源是受限的,而我们需要习惯在限定条件下权衡。
  7. 撰写工作笔记,善于总结,习惯去体悟成长的脚步。
发布了12 篇原创文章 · 获赞 16 · 访问量 1441

猜你喜欢

转载自blog.csdn.net/zhangmalong/article/details/103933188
今日推荐