智能程序设计(C语言)


函数 (I)

计算机学院    杨已彪

yangyibiao@nju.edu.cn

提纲

  • 为什么需要函数: 回文素数案例与设计原则

  • 函数定义与调用 (案例: 素数判断)

  • 返回类型 void 与无参函数

  • 函数声明

  • 参数传递 (传值、swap 陷阱、类型转换)

  • return 与程序终止


案例: 回文素数

输入 \(m\)、\(n\) (\(m \le n\)), 完成三个任务:

  1. 输出 \([m, n]\) 里全部的回文素数 (既是素数、又是回文数)
  2. 统计 \([m, n]\) 里素数有多少个
  3. 判断 \(n\) 是不是回文数

样例: 输入 100 200 → 回文素数 101 131 151 181 191, 素数个数 21, 200 不是回文数

现场演示: 只用之前学的循环, 不用函数, 把这个程序写出来

先想想: 三个任务里, 哪道检查会被反复使用? 第 5 周是不是写过其中一道?

⚡ 在 VS Code 中打开 palindrome-prime.c (参考实现)
不用函数: 检查写了四遍

/* 三个任务: 不用函数 */
#include <stdio.h>

int main(void)
{
  int m, n, i, d, rev, t;
  int count = 0;

  printf("输入 m n (m<=n): ");
  scanf("%d%d", &m, &n);

  for (i = m; i <= n; i++) {
    /* 任务 1: 输出回文素数 */
    /*   素数检查 (第一次) */
    int is_prime = 1;
    for (d = 2; d <= i / d; d++) {
      if (i % d == 0) {
        is_prime = 0;
        break;
      }
    }
    /*   回文检查 (第一次) */
    t = i; rev = 0;
    while (t > 0) { rev = rev * 10 + t % 10; t = t / 10; }
    if (is_prime && rev == i) {
      printf("%d ", i);
    }

    /* 任务 2: 统计素数个数 */
    /*   素数检查 (第二次, 一字不差) */
    is_prime = 1;
    for (d = 2; d <= i / d; d++) {
      if (i % d == 0) {
        is_prime = 0;
        break;
      }
    }
    if (is_prime) {
      count++;
    }
  }
  printf("\n素数个数: %d\n", count);

  /* 任务 3: 判断 n 是不是回文数 */
  /*   回文检查 (第二次, 一字不差) */
  t = n; rev = 0;
  while (t > 0) { rev = rev * 10 + t % 10; t = t / 10; }
  if (rev == n) {
    printf("%d 是回文数\n", n);
  } else {
    printf("%d 不是回文数\n", n);
  }
  return 0;
}
  • 素数检查出现两次、回文检查出现两次——一模一样的代码, 纯复制粘贴

  • 三个任务里混着四段检查, main 已经 40 多行, 想看清"程序在干什么"都难

  • 素数检查要改 (比如 d * d <= i 优化成 d <= i / d), 两处都要改; 漏改一处, 统计和输出对不上, 结果难以排查

palindrome-prime.c

用函数: 检查各写一遍

/* 三个任务: 用函数 */
#include <stdio.h>

int is_prime(int n)          /* 素数检查: 写一次 */
{
  int d;

  if (n <= 1) {              /* 1 和 0 不是素数 */
    return 0;
  }
  for (d = 2; d <= n / d; d++) {
    if (n % d == 0) {
      return 0;
    }
  }
  return 1;
}

int is_palindrome(int n)     /* 回文检查: 写一次 */
{
  int rev = 0, t = n;

  while (t > 0) {
    rev = rev * 10 + t % 10;
    t = t / 10;
  }
  return rev == n;
}

int main(void)
{
  int m, n, i, count = 0;

  printf("输入 m n (m<=n): ");
  scanf("%d%d", &m, &n);

  for (i = m; i <= n; i++) {
    /* 任务 1: 输出回文素数 */
    if (is_prime(i) && is_palindrome(i)) {
      printf("%d ", i);
    }
    /* 任务 2: 统计素数个数 */
    if (is_prime(i)) {
      count++;
    }
  }
  printf("\n素数个数: %d\n", count);

  /* 任务 3: 判断 n 是不是回文数 */
  if (is_palindrome(n)) {
    printf("%d 是回文数\n", n);
  } else {
    printf("%d 不是回文数\n", n);
  }
  return 0;
}
  • 两道检查各写一遍; is_prime 调用 2×(n-m+1) 次, is_palindrome 只调用素数个数 + 1 次 (&& 短路)——零复制

  • main 只剩三个任务的一行行调用, 程序在干什么一目了然

  • 检查要改? 只改一处

  • if (is_prime(i) && is_palindrome(i)): 函数的返回值可以直接当条件用——这是今天要学的核心玩法

  • 顺手补了 n <= 1 守卫: 原版把 1 当素数; 用 1 10 跑一遍会看到行为变化——测试发现旧 bug.

palindrome-prime-re.c

软件设计原则

同样一段检查要抄很多遍, 改 bug 就要改很多处——函数给代码起名字, 一次写好、处处使用

🔁 复用

同样的逻辑只写一遍, 需要时调用即可 —— 改 bug 只改一处

🧩 模块化

程序拆成小块, 更容易调试、理解 —— 每块只做一件事

📦 封装

隐藏实现细节, 只暴露有限、必要的接口

刚才的 is_prime 和 is_palindrome 就是例证: 各写一遍、反复调用——复用、模块化、封装, 一个案例全用上


函数的定义和调用

函数是打包在一起并起名字的一组语句——每个函数本质上都是一个自带声明和语句的小程序; 好处是代码复用 (同一段逻辑只写一次)、模块化 (程序分成更容易理解和修改的小块)、可在其他程序中重用。

其实你早就在用函数了——printf 和 scanf 就是标准库提供的函数, 它们的声明来自头文件:

int printf(const char *format, ...);
int scanf(const char *format, ...);
char str[1024];
scanf("%s", str);
int a, b;
scanf("%d%d", &a, &b);
printf("a + b is : %d", a + b);

函数定义 · 语法骨架

代码
返回类型 函数名(形式参数)
{
  声明
  语句
}
double
average(double a, double b)
{
  return (a + b) / 2;
}
要点
  • 函数的 返回类型 是返回值的类型

  • 规则: 不能返回数组; void 表示没有返回值; C89 省略默认 int; C99 不允许省略

  • 返回类型很冗长时 (如 unsigned long int), 也有的习惯把它单独放一行——左栏 double 就是这么放的


函数定义 · 参数列表与函数体

double average(double a, double b)
{
  double sum;       /* declaration */
 
  sum = a + b;      /* statement */
  return sum / 2;   /* statement */
}
📌 参数列表

每个形式参数前都要说明类型, 参数之间用逗号分隔

没有形式参数时, 括号里写 void

📌 函数体

由大括号包围, 里面可以写声明和语句

函数体内声明的变量, 其他函数无法访问或修改

C89: 声明必须写在语句之前; C99: 声明与语句可以混合 (使用前声明过即可)


函数定义 · 空函数体

返回类型为 void 的函数, 函数体可以为空:

void sort(int a[], int n)
{
}
  • 空函数体 = 占位 (stub): 开发时先把所有函数定义好 (哪怕空着), 程序就能编译、运行

  • 之后再逐个填充实现 —— 像盖房子: 先搭建框架, 再逐步完善

  • 先把函数骨架建好, 程序就能编译、运行; 排序的实现, 等数组作参数讲完再填


程序: 素数判断

代码

开场案例里的 is_prime——现在逐行拆解它的定义。之前用循环写过素数判断, 今天把它函数化.

int is_prime(int n)
{
  int d;

  if (n <= 1) {            /* 1 和 0 不是素数 */
    return 0;
  }
  for (d = 2; d <= n / d; d++) {
    if (n % d == 0) {
      return 0;              /* 找到因子, 不是素数 */
    }
  }
  return 1;                  /* 没找到因子, 是素数 */
}
  • 开头的 int 是返回类型: 是素数返回 1, 不是返回 0

  • n 是形式参数, 表示待判断的数; 调用时复制实际参数的值

要点
  • 函数体由大括号包围, 里面是声明 int d 和循环

  • 函数可以有多条 return: 执行到哪条, 就从哪条返回

  • 调用需要函数名和实际参数: is_prime(97)、is_prime(m) 都是合法调用

  • 实际参数可以是任意同类型的表达式, 不一定是变量


程序: 素数判断 (prime.c)

/* prime.c: 输出 2 ~ 100 的全部素数 */
#include <stdio.h>

int is_prime(int n)
{
  int d;

  if (n <= 1) {            /* 1 和 0 不是素数 */
    return 0;
  }
  for (d = 2; d <= n / d; d++) {
    if (n % d == 0) {
      return 0;
    }
  }
  return 1;
}

int main(void)
{
  int i;

  for (i = 2; i <= 100; i++) {
    if (is_prime(i)) {
      printf("%d ", i);
    }
  }
  printf("\n");
  return 0;
}
  • 把 is_prime 的调用放在 if 里, 返回值直接当条件用——非 void 函数的调用就是一个值

  • 2 ~ 100 的素数:

2 3 5 7 11 13 17 19 23 29 31 37 41 43 47 53 59 61 67 71 73 79 83 89 97

  • 对比之前: 那时要判断一个数, 得把循环抄一遍; 现在写一次, 判断 99 次

练习: 用同样方式写 gcd(a, b)——辗转相除法 (大数除小数取余, 直到余数为 0)


返回类型: void

函数没有返回值, 返回类型就写 void:

void print_count(int n)
{
  printf("%d\n", n);
}

没有参数时, 括号里写 void, 调用时也必须写空括号:

void print_line(void)
{
  printf("====\n");
}

void 是一种没有值的类型

print_count 的调用必须自成一个语句: print_count(i);


print_line 的调用: print_line();——括号不能省, 否则编译器把它当成变量


函数调用

average(x, y)
print_count(i)
print_line()
print_line;   /*** WRONG ***/
print_count(i);
print_line();
avg = average(x, y);
if (average(x, y) > 0) {
  printf("Average is positive\n");
}
printf("The average is %g\n", average(x, y));

函数调用由函数名和用括号括起来的实际参数列表组成:

如果没有括号, 就无法调用函数:

该语句是合法的, 但没有任何作用.


void函数的调用后边始终跟着分号, 使该调用成为语句:

调用非void函数会产生一个值, 该值可存储在变量中或打印等:


函数调用 · 丢弃返回值

average(x, y);  /* discards return value */
num_chars = printf("Hi, Mom!\n");
printf("Hi, Mom!\n"); /* discards return value */
(void) printf("Hi, Mom!\n");
📌 (void) 标注
返回值故意不要时, 调用前加 (void)——让别人知道是故意的, 而不是忘了
📌 丢弃返回值

非 void 函数的返回值不需要时, 可以把它丢掉——average(x, y); 就是表达式语句: 算出值, 不保存

📌 printf 也有返回值

printf 返回它打印的字符个数——num_chars = printf("Hi, Mom!\n"); 之后 num_chars 为 9 (可自行验证)

通常我们直接丢弃 printf 的返回值


函数声明:先调用后定义与隐式声明

#include <stdio.h>
 
int main(void)
{
  double x, y, z;
 
  printf("Enter three numbers: ");
  scanf("%lf%lf%lf", &x, &y, &z);
  printf("Average of %g and %g: %g\n", x, y, average(x, y));
  printf("Average of %g and %g: %g\n", y, z, average(y, z));
  printf("Average of %g and %g: %g\n", x, z, average(x, z));

  return 0;
}
 
double average(double a, double b)
{
  return (a + b) / 2;
}

C 不要求函数的定义在调用之前——可以把 average 的定义放在 main 之后; 但 main 调用它时, 编译器没有任何信息.

  • 不报错, 而是假设它返回 int——为该函数创建了隐式声明; 实参个数与类型也无法检查

  • 后面遇到真实定义 (返回 double) 才会报错

  • 避免方法一: 定义总写在调用之前——可惜有时做不到, 且顺序不自然、难读

✗ 没有原型:编译器只能猜main 调用average(x, y)编译器没见过→ 隐式声明 int之后遇到定义→ 类型冲突✓ 有原型:先声明,后调用double average(double, double);调用前已见过按 double 检查实参 int → double→ 正确C99:调用前必须先声明或定义,否则直接报错

函数声明 · 原型

代码
#include <stdio.h>
double average(double a, double b);   /* 声明 */

int main(void)
{
  double x, y, z;
  …
  printf("Average: %g\n", average(x, y));
  …
}

double average(double a, double b)    /* 定义 */
{
  return (a + b) / 2;
}
返回类型 函数名(参数);   /* 原型 */
double average();        /* 旧式声明 */
要点
  • 调用前先声明函数, 给编译器一份 "简要介绍"; 声明必须与定义一致 (返回类型、参数个数与类型)

  • 这类声明称为函数原型; 形参名可省略 (如 double average(double, double);), 但最好写上

  • 旧式声明 double average(); 是 C89 遗留: 括号为空, 编译器不检查参数——尽量不用

  • C99 规则: 调用前必须先声明或定义, 否则出错 (上一页的隐式声明正是反面教材)


实际参数 (上)

计算数字 x 的 n 次幂 \(x^n\):

int power(int x, int n)
{
  int i, result = 1;

  for (i = 1; i <= n; i++) {
    result = result * x;
  }
  return result;   /* x 的 n 次幂 */
}

调用: printf("%d\n", power(2, 5)); → 输出 32

C 里参数是按值传递的:

  • 调用函数时, 计算出每个实际参数的值, 并将其赋值给相应的形式参数

  • 函数里改形参不影响实参——形参只是实参的副本


按值传递既有优点也有缺点:

  • 优点: 形参可以当函数内的普通变量用, 少声明几个变量 (下一页的 power 就这么写)

  • 缺点: 想在函数里 "改外面" 的变量, 做不到——下一页的 decompose 就是典型例子


实际参数 (下)

int power(int x, int n)
{
  int result = 1;

  while (n-- > 0) {
    result = result * x;
  }

  return result;
}
void decompose(double x, long int_part, double frac_part)
{
  int_part = (long) x;
  frac_part = x - int_part;
}
decompose(3.14159, i, d);
📌 传值的优点

n 是原始指数的副本, 函数内可以直接修改它 (while (n-- > 0)), 不再需要变量 i

📌 传值的代价

想在函数里 "改外面" 的变量就做不到——decompose 想分解出整数部分和小数部分, 修改形参也没用

decompose(3.14159, i, d) 之后, i、d 纹丝不动

后面的指针部分里, decompose 才能真正奏效 (swap 同理)


演示: swap 为什么不生效?

代码
void swap(int a, int b) {
    int t = a;  a = b;  b = t;      /* 交换的是 a、b 的值 */
}

int main(void) {
    int x = 3, y = 5;
    swap(x, y);
    printf("x = %d, y = %d\n", x, y);
    return 0;
}

运行结果:

x = 3, y = 5
main(原件)swap 的形参(复印件)交换后(仍是副本)x = 3y = 5a = 3b = 5a = 5b = 3复印复印交换x、y 仍是 3、5形参 a、b 只是副本;函数返回,副本销毁 → 外面的 x、y 不受影响
要点
  • 传值调用: a、b 拿到 x、y 的值 (即副本), 函数内交换的是 a、b 的值, 与外面的 x、y 没有关系

  • 换个角度看: 如果调用 swap(x + 6, y + 5), 传进去的是表达式的值——显然没人指望 x、y 被改; swap(x, y) 传的同样是值, 不是变量本身

  • 这是新手最困惑的现象之一: 函数明明执行了, 外界却看不到任何变化

  • 记住这个悬念——后面的指针部分, 用 swap(&x, &y) 就能真正交换

⚡ 在 VS Code 中打开 swap.c

实际参数的转换 (1/2)

C 允许实际参数的类型与形式参数的类型不匹配: 怎么转换, 取决于调用前编译器有没有见过原型

double average(double a, double b)
{
  return (a + b) / 2;
}

int main(void)
{
  int x = 3, y = 5;
  printf("%g\n", average(x, y));   /* int 实参 */
  return 0;
}

见过原型 (本页): 每个参数的值被隐式转换为形参的类型, 就像赋值一样

  • average(x, y): x、y 是 int, 传给 double 形参时自动转成 double——安全, 结果正确 (输出 4)

没见过原型 (下一页): 执行默认参数提升, 危险——square 的例子


实际参数的转换 (2/2)

#include <stdio.h>
 
int main(void)
{
  double x = 3.0;
  printf("Square: %d\n", square(x));
 
  return 0;
}
 
int square(int n)
{
  return n * n;
}
printf("Square: %d\n", square((int) x));
📌 没有原型时: 默认参数提升
  • float 实参 → 转 double; char/short 实参 → 转 int (整数提升)

📌 危险: square 的例子

编译器不知道 square 要 int——x 是 double, 提升对它无效, 函数却按 int 解读传入的 double → 结果无效

📌 两个修复
  • 调用前强制转换: square((int) x)

  • 更好的办法: 调用前提供原型 (C99 里没声明就调用直接是错误)


return语句 (上)

代码
return 表达式;
return 0;
return status;
return n >= 0 ? n : 0;
要点
  • 非 void 函数必须用 return 指定返回值

  • 表达式通常是常量或变量, 也可以是更复杂的表达式 (n >= 0 ? n : 0)

📌 类型转换

return 表达式的类型与返回类型不匹配时, 会被隐式转换为返回类型

声明返回 int 却 return 了 double, 值被截断成 int


return语句 (下)

代码
return;  /* return in a void function */
void print_int(int i)
{
  if (i < 0) {
    return;
  }
  printf("%d", i);
}
void print_count(int n)
{
  printf("%d\n", n);
  return;   /* OK, but not needed */
}
要点
  • 不带表达式的 return; 只用于 void 函数, 作用是提前返回——左栏 print_int: i < 0 时直接返回, 后面的打印不执行

  • void 函数末尾的 return; 可以写, 但没必要 (左栏 print_count), 函数自然结束

  • main 可以不写 return (默认返回 0); 其他非 void 函数必须显式 return

  • 非 void 函数忘了写 return, 再去用它的返回值——行为是未定义的: 可能拿到垃圾值、可能是 0、可能崩溃, 每次运行都可能不同, 编译器还不一定报错


程序终止

代码
int main(void)
{
  …
}
main()
{
  …
}
要点
  • 通常 main 的返回类型是 int

  • 老式 C 常省略返回类型 (默认 int)——C99 起不合法, 不要省略; 参数列表里的 void 省略合法, 但最好写上

📌 状态码

main 的返回值是状态码: 正常结束返回 0; 异常结束返回非 0

每个程序都返回状态码是好习惯


exit函数

代码
exit(0);   /* normal termination */
exit(EXIT_SUCCESS);
exit(EXIT_FAILURE);
return 表达式;
exit(表达式);
要点
  • 结束程序的两种方式: main 里 return, 或调用 <stdlib.h> 的 exit——参数含义相同 (状态码)

  • 0 有点模糊, 所以 C 提供宏: EXIT_SUCCESS (正常) / EXIT_FAILURE (异常), 定义在 <stdlib.h>, 通常为 0 和 1

📌 return 与 exit 的区别
  • return 只有由 main 执行时才结束程序

  • exit 任何函数里调用都会结束程序


课堂小测

  1. 概念: 函数定义由哪几部分组成?return 的作用是什么?
  2. 概念: C 语言中参数按什么方式传递?实参和形参是什么关系?
  3. 代码阅读: 调用 is_prime(9) 和 is_prime(11) 各返回什么?is_prime 里的 return 0 执行后, 循环还会继续吗?
  4. 纠错: 下面的函数想交换两个变量的值, 调用后为什么 x、y 没变?该怎么改?(后面的指针部分解答)
void swap(int a, int b) {
    int t = a;  a = b;  b = t;
}



未完待续