器→工具, 术→技巧, 研发, 编程语言

C语言:控制流语句

钱魏Way · · 117 次浏览

C语言中,程序默认的执行顺序是按语句出现的先后顺序按序执行。如果你想要让程序做一些决定,根据不同的情况做不同的事情,例如根据时间打印“早上好”或者“晚上好”,这可以通过控制流语句实现。

分支结构

a ? b : c

三元表达式是最简单的分支结构,具体逻辑为如果a为true,则执行b,否则执行c。

if…else…

当使用if和else,主要需要注意的是“悬空”else的问题。C语言中默认else与离它最近的if绑定。

switch…case…

该分支结构中,传入的条件只能是int型变量(包含char、enum这些int型的拓展)。每个分支需要以break结束,否则分支会依次执行下去不会停止。

循环控制

while (表达式) 语句

当表达式为真时,执行语句,知道表达式不成立。

do 语句 while (表达式)

无论表达式是否为真,语句至少执行一遍。

for(表达式1; 表达式2; 表达式3)

  • 表达式1:循环开始前的初始化步骤
  • 表达式2:用来控制循环的终止(只要表达式为真,循环持续进行)
  • 表达式3:每次循环中最后被执行的操作

for表达式等价于如下while循环:

表达式1;
while(表达式2){
 语句
 表达式3;
}

备注:C99后,变量可以直接到表达式1中声明。

退出循环

break

作用:

  • 跳出switch语句
  • 跳出do、while、for循环

要点:只能跳出一层嵌套

continue

作用:跳过循环体剩余部分(跳转到该次循环末尾)

要点:只能应用与循环语句

goto

goto可以将程序跳转到任何标号的语句处。如果使用太多goto语句,程序代码会变得相当不宜阅读,所以只有在非常有必要时才应该使用goto语句,比如从很深的嵌套循环中跳出。

使用示例:

#include <stdio.h>

int main() {
    int i, j;

    for (i = 0; i < 10; i++) {
        printf("Outer loop executing. i = %d\n", i);
        for (j = 0; j < 3; j++) {
            printf(" Inner loop executing. j = %d\n", j);
            if (i == 2)
                goto stop;
        }
    }

    /* This message does not print: */
    printf("Loop exited. i = %d\n", i);

    stop:
    printf("Jumped to stop. i = %d\n", i);
}

输出内容:

Outer loop executing. i = 0
 Inner loop executing. j = 0
 Inner loop executing. j = 1
 Inner loop executing. j = 2
Outer loop executing. i = 1
 Inner loop executing. j = 0
 Inner loop executing. j = 1
 Inner loop executing. j = 2
Outer loop executing. i = 2和
 Inner loop executing. j = 0
Jumped to stop. i = 2

相关内容:setjmp()、longjmp()。见下文。

关于是否要在C语言代码中使用GOTO语法存在很大的争论,关于GOTO语句,其中两篇最著名的文章为:

其中Dijkstra主张完全摒弃goto语句的使用,Knuth则主张严格的去使用goto语句,虽然主张不一样,但是他们都同意:代码是静态的文本,计算是动态的过程;静态的代码作为描述动态的计算的媒介,要让程序员能够清楚地了解reason about computation才不至于引入Bug。

结构化编程的顺序、分支、循环三种控制结构提供了简单的控制流。如果画成流程图,形状都简单。顺序结构自不必说,每一条语句都是一个盒子,只有一个入口一个出口。同时,可以把顺序结构的连续若干条语句整体视作一个盒子,只有一个入口一个出口。而分支结构、循环结构在必要的时候也可以视作一个盒子,只有一个入口一个出口。这两种结构的内部流程,一方面可以局部地看,另一方面可以把内部的嵌套结构视作盒子。那么在不使用goto语句,而只使用三种控制结构的情况下,任何一个C函数的流程图,通过把适当的连续的语句视作一个盒子,都可以是三种控制结构之一的最简单形态。而这种粗粒度的流程图的每一个盒子,又可以局部地看。这就使得“关注点分离”成为可能。于是程序员可以相对较容易地推断程序在某一时刻是否满足期望的状态和不变性(invariant)。

goto语句的问题在于,没有原则的goto语句的使用(undisciplined use of goto statements)容易使程序控制流变乱,使得上述由粗粒度到细粒度的阅读变得困难,背离了关注点分离原则,严重影响程序员reason about computation。Goto语句甚至可以使得流程图不是一个平面图(planar graph)。

Knuth赞成节制地使用goto语句。如果有什么原则要遵循的话,我想应该是让程序员能够容易地reason about computation。Knuth在他的作品TeX里多处使用了goto语句,但是代码仍然很清晰。有兴趣的读者可以参阅 TeX: The Program。C++ STL的设计者Alexander Stepanov也是Knuth关于goto语句看法的追随者。他在给A9员工的课程中提到,在程序逻辑可以建模成有限状态机(finite state automata)的时候,就可以使用goto语句。

那么问题来了,如何有限定的去使用GOTO?答案是限定为向外层、向后跳。

错误处理

assert.h

assert通常被翻译为断言,有时也被称为诊断,主要功能是声明某种东西为真,如果为假则向标准错误打印一条诊断信息并终止程序,如果为真则不做任何操作,程序继续执行。函数原型如下:

void assert(int expression)

使用示例:

#include <stdio.h>
#include <assert.h>

void main() {
    while(1){
        int a, b;
        double c;
        scanf("%d/%d", &a, &b);
        assert(b != 0);
        c = (double) a / b;
        printf("%lf", c);
    }
}

以上为一个简单的除法程序,我们在这里断言分母不为0,如果为0,则会输出报错信息(如果不使用断言,当分母为0时,c语言默认返回无穷大inf)。具体如下:

5/2
2.500000
4/0
LearnC: /home/qw/CLionProjects/LearnC/main.c:9: void main(): Assertion `b != 0' failed.

assert有一个缺点:因为它引入了额外的检查,因此会增加程序的运行时间,偶尔使用一次assert可能对程序的运行速度没有很大的影响,所以通常在测试的使用使用assert,正式的时候不使用。通常禁用assert有两种方式,一种是在assert.h文件引入前,添加#define NDEBUG,另外一种方式是在编译时加上-DNDEBUG,当NDEBUG被定义后,预处理器将丢弃所有的断言,这样就消除了对性能的影响,而不用将所有断言从源文件移除。

errno.h

标准库中的一些函数通过向errno中声明的int类型errno变量存储一个错误码来表示有错误发生。我们可以在调用库函数之后检查errno是否为0来判断函数在调用过程中是否发生错误。

#include <stdio.h>
#include <math.h>
#include <errno.h>

int main(void)
{
    errno = 0;
    sqrt(-1);
    printf("%d\n",errno);
    return 0;
}

需要注意,在使用errno前需要手动置0,上一次错误产生后该值并不会清0。以上代码执行后,输出的内容为33,33到底是什么意思内。在我的电脑中,errno定义的内容在

  • /usr/include/asm-generic/errno-base.h
  • /usr/include/asm-generic/errno.h

具体内容类似:

/* SPDX-License-Identifier: GPL-2.0 WITH Linux-syscall-note */
#ifndef _ASM_GENERIC_ERRNO_BASE_H
#define _ASM_GENERIC_ERRNO_BASE_H

#define	EPERM		 1	/* Operation not permitted */
#define	ENOENT		 2	/* No such file or directory */
#define	ESRCH		 3	/* No such process */
#define	EINTR		 4	/* Interrupted system call */
#define	EIO		 5	/* I/O error */
#define	ENXIO		 6	/* No such device or address */
#define	E2BIG		 7	/* Argument list too long */
#define	ENOEXEC		 8	/* Exec format error */
#define	EBADF		 9	/* Bad file number */
#define	ECHILD		10	/* No child processes */
#define	EAGAIN		11	/* Try again */
#define	ENOMEM		12	/* Out of memory */
#define	EACCES		13	/* Permission denied */
#define	EFAULT		14	/* Bad address */
#define	ENOTBLK		15	/* Block device required */
#define	EBUSY		16	/* Device or resource busy */
#define	EEXIST		17	/* File exists */
#define	EXDEV		18	/* Cross-device link */
#define	ENODEV		19	/* No such device */
#define	ENOTDIR		20	/* Not a directory */
#define	EISDIR		21	/* Is a directory */
#define	EINVAL		22	/* Invalid argument */
#define	ENFILE		23	/* File table overflow */
#define	EMFILE		24	/* Too many open files */
#define	ENOTTY		25	/* Not a typewriter */
#define	ETXTBSY		26	/* Text file busy */
#define	EFBIG		27	/* File too large */
#define	ENOSPC		28	/* No space left on device */
#define	ESPIPE		29	/* Illegal seek */
#define	EROFS		30	/* Read-only file system */
#define	EMLINK		31	/* Too many links */
#define	EPIPE		32	/* Broken pipe */
#define	EDOM		33	/* Math argument out of domain of func */
#define	ERANGE		34	/* Math result not representable */

#endif

可以看到这里的错误是参数超过函数的定义范围。像这样每次发生错误需要打开定义文件查看具体什么错误还是比较麻烦的,C语言中还提供了一个strerror函数,可以将具体的错误内容输出出来。需要注意的是strerror并不是存在与errno.h中,而是存在于string.h中,具体使用方法如下:

#include <stdio.h>
#include <math.h>
#include <errno.h>
#include <string.h>

int main(void)
{
    errno = 0;
    sqrt(-1);
    printf("%d\n",errno);
    fprintf(stderr, "%s\n", strerror(errno));
    return 0;
}

输出内容为:

33
Numerical argument out of domain

除了strerror外,还有另外一个函数也可以输出错误信息,那就是perror。perror定义在stdio.h中,具体使用方式如下:

#include <stdio.h>
#include <math.h>
#include <errno.h>

int main(void)
{
    errno = 0;
    sqrt(-1);
    if (errno)
        perror("Error Content");
    return 0;
}

输出内容为:

Error Content: Numerical argument out of domain

可以看到这里输出的内容为传入“字符串: 错误信息”。备注以上内容会输出到stderr。

signal.h

signal.h提供了处理异常情况的工具。C语言中将异常情况称为信号。信号有2种类型:运行时错误和发生程序以外的错误(例如用户中断目前正在运行的程序)。当有错误或外部事件发生时,我们称为一个信号。大多数信号是异步的:它们可以在程序执行的任意时刻发生,而不仅仅是在程序员所知道的特定时刻。由于信号可能会在任意想不到的地方发生,因此需要用独特的方式处理他们。

signal.h定义了一系列的宏,比较常见的。下表列出了一些常见信号:

信号名称 数字表示 说明
SIGHUP 1 终端挂起或控制进程终止。当用户退出Shell时,由该进程启动的所有进程都会收到这个信号,默认动作为终止进程。
SIGINT 2 键盘中断。当用户按下<Ctrl+C>组合键时,用户终端向正在运行中的由该终端启动的程序发出此信号。默认动作为终止进程。
SIGQUIT 3 键盘退出键被按下。当用户按下<Ctrl+D>或<Ctrl+\>组合键时,用户终端向正在运行中的由该终端启动的程序发出此信号。默认动作为退出程序。
SIGFPE 8 发生致命的运算错误时发出。不仅包括浮点运算错误,还包括溢出及除数为0等所有的算法错误。默认动作为终止进程并产生core文件。
SIGKILL 9 无条件终止进程。进程接收到该信号会立即终止,不进行清理和暂存工作。该信号不能被忽略、处理和阻塞,它向系统管理员提供了可以杀死任何进程的方法。
SIGALRM 14 定时器超时,默认动作为终止进程。
SIGTERM 15 程序结束信号,可以由 kill 命令产生。与SIGKILL不同的是,SIGTERM 信号可以被阻塞和终止,以便程序在退出前可以保存工作或清理临时文件等。

以下为我的电脑中定义的一些内容:

#define _NSIG		64
#define _NSIG_BPW	__BITS_PER_LONG
#define _NSIG_WORDS	(_NSIG / _NSIG_BPW)

#define SIGHUP		 1
#define SIGINT		 2
#define SIGQUIT		 3
#define SIGILL		 4
#define SIGTRAP		 5
#define SIGABRT		 6
#define SIGIOT		 6
#define SIGBUS		 7
#define SIGFPE		 8
#define SIGKILL		 9
#define SIGUSR1		10
#define SIGSEGV		11
#define SIGUSR2		12
#define SIGPIPE		13
#define SIGALRM		14
#define SIGTERM		15
#define SIGSTKFLT	16
#define SIGCHLD		17
#define SIGCONT		18
#define SIGSTOP		19
#define SIGTSTP		20
#define SIGTTIN		21
#define SIGTTOU		22
#define SIGURG		23
#define SIGXCPU		24
#define SIGXFSZ		25
#define SIGVTALRM	26
#define SIGPROF		27
#define SIGWINCH	28
#define SIGIO		29
#define SIGPOLL		SIGIO
/*
#define SIGLOST		29
*/
#define SIGPWR		30
#define SIGSYS		31
#define	SIGUNUSED	31

/* These should not be considered constants from userland.  */
#define SIGRTMIN	32
#ifndef SIGRTMAX
#define SIGRTMAX	_NSIG
#endif

signal提供了两个函数raise和signal。

  • raise:动触发信号。
  • signal:监测并处理特定信号。

raise函数使用起来非常的简单,只需将信号编码传递给他即可。signal函数共有两个参数,第一个为特定的信号编码,第二个为处理这个信号的函数的指针,其中处理函数必须有一个int型的参数(信号编码会传入),并返回为void。

#include <signal.h>
#include <stdio.h>

volatile sig_atomic_t gSignalStatus;

void signal_handler(int signal) {
    gSignalStatus = signal;
}

int main(void) {
    signal(SIGINT, signal_handler);

    printf("SignalValue: %d\n", gSignalStatus);
    printf("Sending signal: %d\n", SIGINT);
    raise(SIGINT);
    printf("SignalValue: %d\n", gSignalStatus);
}

sig_atomic_t,这是 int 类型,在信号处理程序中作为变量使用。它是一个对象的整数类型,该对象可以作为一个原子实体访问,即使存在异步信号时,该对象可以作为一个原子实体访问。除非信号是由调用abort函数或raise函数引发的,否则新还处理函数不应该调用库函数或具有静态存储期限的变量。注意:如果信号是由信号处理函数引发的,则有可能引发无限递归,所以处理函数尽可能的简单。另外signal.h中也预定义了信号处理函数:

  • SIG_DFL:按默认方式处理信号(大多数情况会导致程序终止)
  • SIG_IGN:忽略该信号
  • SIG_ERR:看起来像信号处理函数,实际上是用来在安装处理函数时检测是否发生错误的。如果一个signal调用失败(即不能对所指定的信号安装处理函数),就会返回SIG_ERR并在errno中传入一个值。使用场景如下:
if (signal(SIGINT, signal_handler) == SIG_ERR){
    perror("signal(SIGINT, signal_handler failed");
}

除了raise函数外,有多种方式向程序发送信号,例如按下<Ctrl+C>组合键会发送SIGINT信号,终止当前进程。还可以通过 kill 命令发送信号,语法为:kill -signal pid

setjmp.h

goto语句只允许局部性跳;也就是“在自己的函数内跳转”。C语言也提供了非局部跳转的库setjmp.h。它常用于深层嵌套的函数调用链。如果某个低层的函数中检测到一个错误,你可以立即返回到顶层函数,不必向调用链中的每个中间层函数返回一个错误标志。

在setjmp.h中最重要的内容就是setjmp宏和longjmp函数。setjmp宏标记程序中的一个位置,随后可以通过longjmp函数跳转到该位置。如果要为将来的跳转标记一个位置,可以调用setjmp宏,调用的参数是一个jmp_buf类型的变量。setjmp会在第一次调用时返回0。代码实例:

#include <stdio.h>
#include <setjmp.h>

jmp_buf env;

void f1(void);
void f2(void);

int main()
{
    if (setjmp(env) == 0)
        printf("setjmp returned 0\n");
    else {
        printf("Program terminates: longjmp called\n");
        return 0;
    }

    f1();
    printf("Program terminates normally\n");
    return 0;
}

void f1(void)
{
    printf("f1 begins\n");
    f2();
    printf("f1 returns\n");
}

void f2(void)
{
    printf("f2 begins\n");
    longjmp(env, 1);
    printf("f2 returns\n");
}

输出内容为:

setjmp returned 0
f1 begins
f2 begins
Program terminates: longjmp called

setjmp宏的最初调用返回0,因此main函数会调用f1,接着,f1调用f2,f2使用longjmp函数将控制全重新转给main函数,而不是返回到f1。当longjmp函数被执行时,控制权重回到setjmp宏调用。这次setjmp宏返回1(就是longjmp调用时所指定的值)。

发表评论

邮箱地址不会被公开。 必填项已用*标注