第15章 编写大型程序

作者: 橡树人 | 来源:发表于2020-03-09 07:55 被阅读0次

英文原版:P349

一个典型的程序由多个源文件(.c)和一些头文件(.h)组成。

  • 源文件包含函数的定义,外部变量等。
  • 头文件包含被多个源文件共享的信息。

本章的主要内容:

  • 15.1节介绍源文件。
  • 15.2节介绍头文件。
  • 15.3节通过实例来介绍如何将一个程序分成源文件和头文件。
  • 15.4节介绍如何构建由多个文件组成的程序,及当该程序的部分发生变化时,如何重新构建该程序。

15.1 源文件

C语言对源文件的规定:

  • 源文件的拓展名为.c
  • 每个源文件包含程序的一部分,主要是函数和变量的定义
  • 必须有一个源文件包含main函数,作为程序的入口

例1 假设要编写一个计算器程序:该程序对用户输入逆波兰记号形式的整数表达式求值,其中在逆波兰表达式里,操作符跟在操作数后面。

示例输入:30 5 - 7 *
示例输出:175

程序设计思路:

  • 使用栈来记录中间结果。
  • 如果程序读到一个数,则将这个数入栈。
  • 如果程序读到一个操作符,则将从栈中弹出两个数,执行该操作,并将结果压入栈中。
  • 当程序读取输入完成,则表达式的结果就保存在栈里。

如何将该程序分成多个源文件?

  • 将读取tokens的函数放源文件token.c;
  • 将诸如pushmake_emptyis_emptyis_full等栈操作相关的函数,及表示栈的变量等放入源文件stack.c;
  • main函数放入源文件calc.c

如何共享函数原型?
由于源文件calc.c要调用源文件stack.c里的函数,所以需要将源文件stack.c中的相关函数的原型放入头文件stack.h中,并在源文件calc.cstack.c中包含该头文件stack.h

源文件stack.h

#include <stdbool.h>

void make_empty(void);
bool is_empty(void);
bool is_full(void);
void push(int i);
int pop(void);

源文件stack.c

#include "stack.h"
#include <stdbool.h>

#define STACK_SIZE 100

//全局变量
int contents[STACK_SIZE];
int top = 0;

void make_empty(vid)
{
    top = 0;
}

bool is_empty(void)
{
    return top == 0;
}

bool is_full(void)
{
    return top == STACK_SIZE;
}

void push(int i)
{
    if (is_full())
    {
        stack_overflow();
    }
    else 
    {
        contents[top++] = i;
    }
}

int pop(void)
{
    if (is_empty())
    {
        stack_overflow();
    }
    else 
    {
        return contents[--top];
    }
}

源文件calc.c

#include "stack.h"

int main(void)
{
  make_empty();

  return 0;
}

15.2 头文件

C语言对头文件的规定:

  • 头文件的拓展名是.h
  • 头文件里包含的是被多个源文件共享的信息,比如函数原型、宏定义、类型定义等;
  • 使用#include指令来引入头文件;

当把一个程序分成多个源文件时会出现如下几个问题:

  • 在一个文件里的函数如何调用定义在另一个文件里的函数?
  • 一个函数如何访问定义在另一个文件里的外部extern变量?
  • 两个文件如何共享同一个宏定义、类型定义?

解决办法:使用预处理指令#include,该指令使得在多个源文件之间共享函数原型、宏定义、类型定义等信息成为可能。

#include指令

功能描述:

  • 告诉预处理器打开一个具体的文件,将该文件的内容插入到当前文件中。

用途:

  • 可实现在多个源文件之间共享函数原型、宏定义、类型定义等信息。

有三种使用形式

  • #inlcude <filename>:会搜索系统头文件目录,比如/usr/include等。
  • #include "filename":首先会搜索当前目录,然后搜索系统头文件目录。
  • #include tokens:对tokens进行扫描,并用宏定义来替换tokens;替换后的#include形式必须跟前两种之一匹配。

例1 使用宏来定义文件名,不用在#include中硬编码文件名

#if defined(IA32)
  #define CPU_FILE "ia32.h"
#elif defined(IA64)
  #define CPU_FILE "ia64.h"
#elif defined(AMD64)
  #define CPU_FILE "amd64.h"
#endif

#include CPU_FILE

如何共享宏定义和类型定义?

许多大型程序都存在被多个源文件共享的宏定义和类型定义。

被多个源文件共享的宏定义和类型定义应该被放入头文件.h

共享宏定义和类型定义有哪些优点?

  • 节省了拷贝定义到所需源文件的时间;
  • 使得修改程序变得更容易,只用修改一个头文件,不用修改使用宏或者类型的所有源文件;
  • 不用再担心出现不同源文件里存在同一个宏或者类型的不同定义的现象;

例1 共享宏定义和类型定义
假设正在编写一个使用宏BOOLTRUEFALSE的程序。
文件file1.cfile2.cfile3.c都需要这3个宏定义。

不是在源文件file1.cfile2.cfile3.c里重复定义这3个宏,而是将这几个宏的定义放入头文件boolean.h中。

源文件boolean.h

#define BOOL int
#define TRUE 1
#define FALSE 0

源文件file1.c

#include "boolean.h"

int main(void)
{
    /* code */
    BOOL is_full;

    is_full = 0;

    return 0;
}

预处理后的文件:file1.i

int main(void)
{

 int is_full;

 is_full = 0;

 return 0;
}

源文件file2.c

#include "boolean.h"

int main(void)
{
    /* code */
    int a = 10;
    if ((a > 9) == TRUE) {

    }
    return 0;
}

预处理后的文件:file2.i

int main(void)
{

 int a = 10;
 if ((a > 9) == 1) {

 }
 return 0;
}

源文件file3.c

#include "boolean.h"

int main(void)
{
    /* code */
    int a = 10;
    if ((a > 11) == FALSE) {

    }
    return 0;
}

预处理后的文件:file3.i

int main(void)
{

 int a = 10;
 if ((a > 11) == 0) {

 }
 return 0;
}

源文件file4.c

#include <stdio.h>
#include "boolean.h"

int main(void)
{

    Bool a;

    a = 10;
    printf("%d\n", a);

    return 0;
}

预处理后的文件:file4.i

int main(void)
{

 Bool a;

 a = 10;
 printf("%d\n", a);

 return 0;
}

如何共享函数原型?

  • 调用定义在另一个文件里的函数f时,一定要确保在调用f前让编译器遇到过函数f的原型

  • 务必在包含函数f定义的文件里的包含有函数f原型的头文件,因为如果在程序中函数f的调用跟函数f的定义不匹配,且没有在包含函数f定义的文件里的包含有函数f原型的头文件的话,则编译器就不会检查函数f的定义是否跟函数f的原型相匹配,会导致很难查找的bug。

例1 为什么要将共享函数的原型放入头文件?

假设源文件file5.c例包含定义在foo.c里的函数f

首先,调用一个没有声明的函数f是有风险的。
如果函数f没有可依赖的原型,则编译器会强制假设函数f的返回值类型是int,调用函数f的实参的个数要跟形参的个数相匹配。而且,实参会根据默认的实参类型提升规则来对实参进行类型的自动转换。编译器的默认假设可能是错的,但是编译器却没办法检查出来,因为编译器一次只能编译一个文件。如果假设是错的,则程序将会运行异常,且不知道为什么会这样。

方法一:在调用函数f的文件里声明函数f
这种方法可以解决上面的问题,但带来了新的问题。比如

  • 有50个文件要调用函数f,如何保证所有50个文件里的函数f的原型都是一样的?
  • 如何保证foo.c里的定义能匹配所有50个文件里的函数f的原型?
  • 如果后续要修改函数f,如何查找所有使用函数f的文件?

方法2:将函数f的原型放入一个头文件,让所有调用函数f的源文件里包含该头文件
因为函数f是在foo.c中定义的,所以命名头文件为foo.h
除了在调用函数f的源文件里包含foo.h外,还需要在foo.c里也包含foo.h,这样可让编译器能够检查在foo.c中的函数f的定义是否跟在foo.h中声明的函数原型相匹配。

共享变量声明

如何在多个源文件之间共享变量i

方法一:

  • 首先,将变量i的定义放入一个源文件中;如果需要初始化,可以在这里完成;
  • 然后在其他需要使用变量i的源文件中包含变量i的声明;

方法二:

  • 将共享变量的声明放在头文件里;
  • 在需要访问该变量的源文件里包含合适的头文件;
  • 在变量定义的源文件里包含变量声明的头文件,以便编译器检查变量的声明和定义之间是否匹配;

如何保证同一个头文件只被包含一次?

首先,在一个源文件里包含两次同一个头文件,会报编译错误,这个很容易发现。
其次,当一个头文件里包含了其他头文件时,也会报编译错误,但这个很难发现。

例1 假设file1.h中包含了file3.h,file2.h中包含了file3.h,prog.c中包含了file1.h和file2.h。

如果不做特殊处理,当编译prog.c时,file3.h就会被编译两次。当file3.h中包含函数定义时,就会报编译时错误。

务必要保护所有的头文件不被多次包含。

源文件file3.h

#include <stdio.h>

#define LEN 10
tydef int Bool

extern int i;

void f(void);

void g(void)
{
  printf("test header file multiple inclusion in g()\n");
}

15.3 示例:将一个程序分解成多个文件

样例输入文件quote

     C         is   quirky,     flawed,         and    an                   
enormous      success.          Although     accidents of         history
  surely      helped, it evidently satisfied a need
           for a system implementation language efficient
    enough to displace assembly language,
       yet sufficiently abstract and fluent to describe
     algorithms and interactions in a wide variety
of en vironments. 
                                     - - Dennis M. Ritchie

样例输出文件newquote

c is quirky, flawed, and an enormous success. Although
accidents of history surely helped, it evidently satisfied a
need for a system implementation language efficient enough
to displace assembly language iyet sufficiently abstract and
fluent to describe algorithms and interactions in a wide
variety of environments. --  Dennis  M.  Ritchie

编写程序justify实现格式化输出的功能。

  • 假设所有词的长度都小于等于20个字符;如果一个词的长度超过20,则使用一个星号*来代替剩余的字符;
  • 输出内容跟输入一样,但要删除额外的空格和空行;
  • 保证每行都被填满:在每行中添加词直到再添加一个或者多个词就导致溢出;
  • 对每行进行校正:在词与词之间添加额外的空格,保证每行的长度是一样的,比如60个字符;保证词与词之间的空格数一样的;最后一行可以不做校正。

程序设计思路:
首先,不能读一个单词,写一个单词。需要将单词读入到行缓冲区里,直到该缓冲区填满一行。
然后,主程序使用循环来实现,比如

for (;;){
  读单词;
  if (没有单词可读了) {
    就将不用校正行缓冲区里的内容,直接写入文件;
    程序终止;
  }
  
  if (行缓冲区满了) {
    对行缓冲区里的内容进行校正,然后写入文件;
    清空行缓冲区;
  }
  将单词添加到行缓冲区中;
}

源文件justify.c

#include <string.h>
#include "line.h"
#include "word.h"

#define MAX_WORD_LEN 20

int main(void)
{
    char word[MAX_WORD_LEN+2];
    int word_len;

    clear_line();
    for (;;) {
        read_word(word, MAX_WORD_LEN+1);
        word_len = strlen(word);
        if (word_len == 0) {
            flush_line();
            return 0;
        }
        if (word_len > MAX_WORD_LEN) {
            word[MAX_WORD_LEN] = '*';
        }
        if (word_len + 1 > space_remaining()) {
            write_line();
            clear_line();
        }
        add_word(word);
    }

    return 0;
}

源文件word.h

#ifndef WORD_H
#define WORD_H

void read_word(char *word, int len);

#endif

源文件word.c

#include <stdio.h>
#include "word.h"


int read_char(void)
{
    int ch = getchar();

    if (ch == '\n' || ch == '\t') {
        return ' ';
    }

    return ch;
}

void read_word(char *word, int len)
{
    int ch, pos = 0;

    while ((ch = read_char()) == ' ') {
        ;
    }
    while (ch != ' ' && ch != EOF) {
        if (pos < len) {
            word[pos++] = ch;
        }
        ch = read_char();
    }
    word[pos] = '\0';
}

源文件line.h

#ifndef LINE_H
#define LINE_H

void clear_line(void);

void add_word(const char *word);

int space_remaining(void);

void write_line(void);

void flush_line(void);

#endif

源文件line.c

#include <stdio.h>
#include <string.h>
#include "line.h"

#define MAX_LINE_LEN 60

char line[MAX_LINE_LEN+1];
int line_len = 0;
int num_words = 0;

int space_remaining(void)
{
    return MAX_LINE_LEN - line_len;
}

void clear_line(void) {
    line[0] = '\0';
    line_len = 0;
    num_words = 0;
}

void flush_line(void)
{
    if (line_len > 0) {
        puts(line);
    }
}

void add_word(const char *word)
{
    if (num_words>0) {
        line[line_len] = ' ';
        line[line_len+1] = '\0';
        line_len++;
    }

    strcat(line, word);
    line_len += strlen(word);
    num_words++;
}

void write_line(void)
{
    int extra_spaces, spaces_to_insert, i, j;

    extra_spaces = MAX_LINE_LEN - line_len;
    for (i=0;i<line_len;i++) {
        if (line[i] != ' ') {
            putchar(line[i]);
        } else {
            spaces_to_insert = extra_spaces/(num_words - 1);
            for (j = 1; j <= spaces_to_insert+1; j++) {
                putchar(' ');
            }
            extra_spaces -= spaces_to_insert;
            num_words--;
        } 

    }
    putchar('\n');
}

编译justify程序

gcc -o justify justify.c line.c word.c

15.5 如何构建多个文件的程序

什么时候会出现外部引用?

  • 当文件a里的函数调用了一个定义在文件b里的函数时;
  • 当文件a里的函数访问了一个定义在文件b里的变量时;

构建一个大型程序,需要两步:

  1. 编译
    在程序里的每个源文件必须被单独地编译;
    头文件不需要编译,当包含该头文件的源文件被编译时自动编译的;
    编译器对每个源文件都生成目标文件,比如在UNIX是.o文件,在Windows上是.obj文件等;
  2. 链接
    链接器将编译步骤中创建的目标文件和库函数代码综合起来生成可执行文件
    链接器负责解析编译器没做的外部引用;

相关文章

  • 第15章 编写大型程序

    英文原版:P349 一个典型的程序由多个源文件(.c)和一些头文件(.h)组成。 源文件包含函数的定义,外部变量等...

  • pinpoint的安装和部署

    简介 Pinpoint是用Java编写的大型分布式系统的APM(应用程序性能管理)工具...

  • ES6 - let、const、var的区别

    为了使JavaScript语言可以用来编写复杂的大型应用程序,成为企业级开发语言,ECMAScript 6.0(简...

  • 名字空间(namespace)

    意义 对于一个较大型的C++程序来说,需要由多个程序员相互协作编写,这样的情况下容易出现不同程序员在需要负责的程序...

  • Python中的模块导入

    在编写大型程序的时候我们会编写大量的类,为了管理和方便后期使用,需要将这些类进行分门别类划分到不同的模块中,当我们...

  • CMake和Make之间的区别

    就是为了编译一个大型程序,你首先编写CMakeLists.txt。然后,通过cmake命令就可以生成makefil...

  • Python的10大集成开发环境和代码编辑器(指南)

    使用IDLE或者Python Shell来编写Python是非常适合于简单程序的,但是这些工具往往将大型的编程项目...

  • Python的10大集成开发环境和代码编辑器(指南)

    使用IDLE或者Python Shell来编写Python是非常适合于简单程序的,但是这些工具往往将大型的编程项目...

  • 在 Vue 3 中管理共享状态

    编写大型 Vue 应用程序可能是一个挑战。 在 Vue 3 应用程序中使用共享状态可以是降低这种复杂性的解决方案。...

  • Flask程序的基本项目结构

    一、前言   尽管在单一脚本中编写小型 Web 程序很方便,但这种方法并不能广泛使用。程序变复杂后,使用单个大型源...

网友评论

    本文标题:第15章 编写大型程序

    本文链接:https://www.haomeiwen.com/subject/iiiydhtx.html