这一章在干嘛?

第 3 章你已经会写单值函数和脚本了,本章把「程序」这个词当真:一个像样的 MATLAB 程序 = 一个主脚本 + 一堆各司其职的函数。本章讲清楚函数的另外两种形态(多返回值、无返回值)、怎么把程序拆成模块、变量在脚本/函数/命令行之间到底谁看得见谁(作用域),以及程序出错后怎么定位和修复。学完你就能组织起几百行、多文件的完整程序,而不是把所有代码塞进一个脚本里。

6.1 更多类型的用户自定义函数

6.2 MATLAB 程序的组织:模块化与子函数

6.3 应用实战:菜单驱动的模块化程序

6.4 变量作用域与持久变量

6.5 调试技术

6.6 实时脚本与代码单元

6.1 更多类型的用户自定义函数

第 3 章写的函数都是「进几个参数、出一个返回值」。MATLAB 函数其实有三种形态:

  1. 返回一个值——第 3 章已学;
  2. 返回多个值——输出参数放在方括号里;
  3. 不返回任何值,只干活(比如打印、画图)。

不管哪种形态,函数定义的结构都一样:函数头(header,第一行)→ 说明注释(help 函数名 时显示的就是它)→ 函数体 → end。函数名必须与存放它的 .m 文件同名,否则调用时会找不到。

6.1.1 返回多个值的函数

对比 C:C 的函数只能有一个返回值,想「多带点东西回来」只能靠指针参数。MATLAB 直接在函数头用方括号列出多个输出参数:

function [area, circum] = areacirc(rad)
% areacirc 同时返回圆的面积和周长
% 格式:areacirc(radius)
 
area = pi * rad .* rad;
circum = 2 * pi * rad;
end

调用时必须用同样数量的变量接住返回值,顺序与函数头一致:

>> [a, c] = areacirc(4)
a =
   50.2655
c =
   25.1327

只写一个变量也能调,但只有第一个返回值被留下,第二个直接丢失——这是初学者常犯的错。想只取第二个,用波浪号占位跳过第一个:[~, c] = areacirc(4)

多返回值函数天生支持向量化

areacirc 里用的是 .*,所以传进去一个半径向量也行,返回的就是面积向量和周长向量——[a, c] = areacirc(1:4) 一次算四个圆。这就是第 5 章向量化思维在函数设计上的体现:写函数时优先让输入输出支持数组。

再看一个三返回值的例子:把总秒数拆成时、分、秒(7515 秒 = 2 小时 5 分 15 秒):

function [hours, minutes, secs] = breaktime(totseconds)
% breaktime 把总秒数拆成时、分、秒
hours   = floor(totseconds / 3600);   % 整除 3600 取小时
remsecs = rem(totseconds, 3600);      % 余下的秒数(局部变量)
minutes = floor(remsecs / 60);
secs    = rem(remsecs, 60);
end
>> [h, m, s] = breaktime(7515)
h =
     2
m =
     5
s =
    15

算法和 C 里拆时分秒完全一样:floor 取商、rem 取余。remsecs 没有出现在输出参数表里,它只是函数内部使用的局部变量(6.4 节细说)。

6.1.2 不返回值的函数:只干活的函数

第二类函数只完成任务(典型如打印),函数头里没有输出参数、也没有等号

function printem(a, b)
% printem 以句子格式打印两个数
fprintf('The first number is %.1f and the second is %.1f\n', a, b)
end
>> printem(3.3, 2)
The first number is 3.3 and the second is 2.0

因为它不返回任何东西,不能出现在赋值语句右边

>> x = printem(3, 5)      % 错误!
Error using printem
Too many output arguments.

记住这个心智模型:无返回值函数的调用本身就是一条语句,和 C 里调用 printf 后加分号一个待遇——不能把它塞进表达式里。

这类函数产生的「外部可见效果」(打印、画图等)称为副作用(side effects)

6.1.3 设计原则:算值的函数别顺手打印

「返回值」和「打印结果」是两种职责,好的做法是分开:函数只算并返回,打印留给调用者。看反例:

function calccircum1(radius)     % 反例:只打印不返回
disp(2 * pi * radius)
end
>> calccircum1(3.3)
   20.7345
>> c = calccircum1(3.3)          % 想存下来?没门
Error using calccircum1
Too many output arguments.

值只在屏幕上一闪而过,后续计算完全用不上。改成返回值:

function circle_circum = calccircum2(radius)   % 正解:返回
circle_circum = 2 * pi * radius;
end
>> circumference = calccircum2(3.3);
>> height = 4;
>> surf_area = circumference * height    % 返回值能继续参与计算
surf_area =
   82.9380

计算圆柱表面积时,直接拿返回值乘高就行。这条原则与 C 里「函数返回结果、调用方决定怎么用」完全同构。

6.1.4 传参:按值传递,参数可以为零

MATLAB 的传参方式是按值传递(call-by-value):调用时把实参的值复制给函数头的形参,函数内改形参不影响外部。这点和 C 传普通变量一致(MATLAB 没有指针,想改调用者的变量,只能把新值作为返回值传回去)。

参数数量也可以是零。无参数的函数,圆括号可有可无,调用时同样随意:

function printrand()          % 无输入无输出
fprintf('The random # is %.2f\n', rand)
end
>> printrand()
The random # is 0.94

6.2 MATLAB 程序的组织:模块化与子函数

6.2.1 模块化程序:主脚本 + 各司其职的函数

**模块化程序(modular program)**把问题拆成若干模块,每个模块实现为一个函数,负责调度它们的主脚本称为主程序(main program)。以「算圆面积」为例,算法三步——取输入、算面积、显示结果——就拆成一个主脚本加三个函数,共四个 .m 文件:

% calcandprintarea.m —— 主脚本
radius = readradius;             % 1. 读半径
area = calcarea(radius);         % 2. 算面积
printarea(radius, area)          % 3. 打印(无返回值,独立成句)
function radius = readradius     % readradius.m
radius = input('Enter the radius: ');
end
 
function area = calcarea(rad)    % calcarea.m
area = pi * rad .* rad;
end
 
function printarea(rad, area)    % printarea.m
fprintf('For a circle with a radius of %.2f inches, \n', rad)
fprintf('the area is %.2f inches squared. \n', area)
end
>> calcandprintarea
Enter the radius: 5.3
For a circle with a radius of 5.30 inches,
the area is 88.25 inches squared.

写模块化程序时最容易乱的是「调用和定义对不上」。核对方法很简单,逐个函数检查两头:

  • radius = readradius;function radius = readradius——调用没传参,头里就没有输入参数;返回一个值,左边就用一个变量接。
  • area = calcarea(radius);function area = calcarea(rad)——传 1 个,收 1 个。
  • printarea(radius, area)function printarea(rad, area)——传 2 个,收 2 个;无返回值,调用独立成句。

注意形参名和实参名不需要相同(radius vs rad),因为传的是值不是变量本身。

6.2.2 子函数:多个函数住一个文件

到目前为止每个函数都独占一个 .m 文件。其实一个文件里可以放多个函数:第一个叫主函数(primary function),文件名与它同名;后面跟的叫子函数(subfunction),只有同一文件里的函数才能调用它——相当于 C 里加了 static 限定的文件内私有函数。适合放「只被主函数使用、外界不需要知道」的辅助逻辑:

% printrectarea.m —— 文件名 = 主函数名
function printrectarea(len, wid)
% 打印矩形面积;内部调用子函数算面积
area = calcrectarea(len, wid);        % 调用下面的子函数
fprintf('For a rectangle with a length of %.2f\n', len)
fprintf('and a width of %.2f, the area is %.2f\n', wid, area)
end
 
function area = calcrectarea(len, wid)   % 子函数:外界不可直接调用
area = len * wid;
end

help 看子函数的注释要加 > 操作符:help printrectarea>calcrectarea

文件里可以有多个函数 ≠ 文件名可以随便起

每个文件只暴露第一个(主)函数给外部调用;子函数是文件私有的。如果发现你在文件外面想调某个子函数,说明它不该是子函数——提升成独立文件吧。

6.3 应用实战:菜单驱动的模块化程序

把前面的东西拼起来,看一个完整的菜单驱动程序(menu-driven program):程序循环显示菜单,用户选什么就调用对应函数,选「退出」才结束。几乎所有交互式程序都是这个骨架。

示例程序探索常数 。数学背景:,指数函数可用麦克劳林级数近似:

程序结构:主脚本 + 4 个函数文件(eoption 显示菜单并校验选择、explaine 打印说明、limite 用极限近似 e、expfn 对比内置 exp(x) 与级数近似),其中 limiteexpfn 各带一个子函数负责输入校验和级数求和。核心骨架如下:

% eapplication.m —— 主脚本
choice = eoption;                  % 显示菜单,取用户选择
while choice ~= 4                  % 4 = 退出
    switch choice
        case 1
            explaine;              % 打印 e 的说明
        case 2
            limite;                % 用极限近似 e
        case 3
            x = input('Please enter a value for x: ');
            expfn(x);              % 对比 exp(x) 与级数近似
    end
    choice = eoption;             % 干完活,再显示一次菜单
end
function choice = eoption          % eoption.m
printchoices
choice = input('');
while ~any(choice == 1:4)          % 校验:不在 1~4 内就重来
    disp('Error - please choose one of the options.')
    printchoices
    choice = input('');
end
end
 
function printchoices              % 子函数:打印菜单
fprintf('1) Explanation\n2) Limit\n3) Exponential function\n4) Exit program\n\n')
end
function outval = appex(x, n)      % expfn.m 中的级数子函数
outval = 1;                        % 第一项,累加器初始化
for i = 1:n
    outval = outval + (x^i) / factorial(i);
end
end
>> eapplication
Enter a positive integer for n: 10
An approximation of e with n = 10 is 2.59
Enter a positive integer for n: 100
An approximation of e with n = 100 is 2.70   % n 越大越接近 2.7183

这个例子集合了本章全部要点:while 驱动菜单循环(第 5 章)、switch 分发任务、每个功能一个函数、所有用户输入都经过错误校验、辅助逻辑下沉为子函数。以后写交互式工具,照这个骨架套即可。

6.4 变量作用域与持久变量

6.4.1 谁看得见谁:基础工作区与局部变量

一个变量的作用域(scope)指它在哪个工作区(workspace)里有效。命令窗口创建的工作区叫基础工作区(base workspace)。规则两条:

  1. 函数里的变量是局部变量(local variable):只在函数执行期间存在,函数一结束就销毁。调用函数不会给基础工作区留下任何变量,命令窗口的变量函数也看不见(除非通过参数传进来)。
>> clear
>> disp(mysum([5 9 1]))
    15
>> who            % 空!mysum 里的 runsum、i 都没了
  1. 脚本和命令窗口共用基础工作区:脚本里定义的变量会留在命令窗口的 who 列表里;反过来,命令窗口已有的变量(比如 vec)也能被脚本直接使用。

第二条看起来方便,实则是坑:脚本偷偷依赖工作区里的旧变量,换个环境一跑就报「变量未定义」。所以教材反复强调——别在命令窗口定义变量给脚本用,要用就作为参数传给函数。也因此很多程序员习惯在脚本开头写 clear,把工作区清干净再跑。

也正因为脚本会污染基础工作区,稍大的程序常把「主脚本」换成「主函数」:全函数化的程序不给基础工作区留任何东西,边界干净。(MATLAB 也有 global 全局变量,但公认是不良风格,知道有这回事即可,别用。)

6.4.2 持久变量:函数的「记忆」

局部变量函数一结束就销毁。如果想让某个变量跨调用存活——比如统计函数被调用了几次——用**持久变量(persistent variable)**声明:

function func1                 % 普通变量版:每次都归零
count = 0;
count = count + 1;
fprintf('The value of count is %d\n', count)
end
 
function func2                 % 持久变量版:记得上一次
persistent count               % 声明(只分配空间,未初始化)
if isempty(count)              % 第一次调用时才初始化
    count = 0;
end
count = count + 1;
fprintf('The value of count is %d\n', count)
end
>> func1
The value of count is 1
>> func1
The value of count is 1        % 每次都从 0 开始
>> func2
The value of count is 1
>> func2
The value of count is 2        % 记住了上一次!

对 C 程序员来说这就是老朋友:persistent 等价于函数内的 static 变量。两点 MATLAB 特有之处:其一,MATLAB 中只有持久变量需要「先声明后使用」,普通变量随手赋值即可;其二,惯用 if isempty(count) 来判断「是不是第一次调用」,从而完成一次性初始化。想重置持久变量,用 clear func2(清除该函数即可,不必 clear functions 全清)。

常见坑:持久变量在脚本和命令窗口里都会累积

持久变量每次调用都更新——不管调用来自脚本还是命令窗口。调试时发现「计数怎么不从 1 开始了」,多半是之前已经调过几次,clear 函数名 重置即可。

6.5 调试技术

程序里的任何错误统称 bug(这个词来自 1940 年代一只飞进继电器的飞蛾——真事)。找错改错的过程叫调试(debugging)。第 5 章介绍的 checkcode 静态检查是第一道防线,这里看运行期的三大类错误:

语法错误(syntax errors):语言用法写错了,比如少引号、拼错关键字。MATLAB 能当场抓住并报错,编辑器还会画波浪线提示:

>> mystr = 'how are you;
Error: A MATLAB string constant is not terminated properly.

运行时错误(run-time errors):语法对、跑起来才炸。典型如数组越界:

>> runtimeEx            % vec 只有 3 个元素,循环却跑到 4
3
4
5
Attempted to access vec(4); index out of bounds because numel(vec)=3.
Error in runtimeEx (line 6)

顺带一提:C 里除零是未定义行为,MATLAB 里除以零返回 Inf,不报错——所以「除数是否为零」要自己检查,别指望 MATLAB 替你炸出来。

逻辑错误(logical errors):程序能跑、不报错,但结果是错的——比如把英寸转厘米写成了除以 2.54。这是最难抓的一类,因为没有任何报错指路。

6.5.1 追踪与断点

逻辑错误的定位靠「看程序到底执行了哪些语句、变量的值到底是什么」。两个工具:

echo 追踪echo 函数名 on 会让每条语句执行时都回显出来,看清走了哪个分支。下面这个函数想判断 x 是否在 3~6 之间,却对所有 x 都打印「In middle of range」:

function testifelse(x)
if 3 < x < 6                    % 有 bug 的条件!
    disp('In middle of range')
else
    disp('Out of range')
end
end

dbstop 在第 6 行设断点,进入调试模式(提示符变成 K>>),手动检查表达式:

>> dbstop testifelse 6
>> testifelse(-2)
K>> x
x = -2
K>> 3 < x
ans = 0
K>> 3 < x < 6
ans = 1                  % 真相大白!
K>> dbcont

问题找到了:3 < x < 6 先算 3 < x 得到 0 或 1,而 0 和 1 都小于 6,所以整个表达式永远为真。这是 C 程序员的经典陷阱——C 里这么写同样是 bug,只是 MATLAB 的「真值就是 0/1 数值」让它的行为更隐蔽。正确写法:x > 3 && x < 6

调试命令三件套:dbstop 设断点、dbcont 继续执行(或 dbstep 单步)、dbquit 退出调试。也可以不用命令:编辑器中代码行左侧那条灰色窄条被称为断点小巷(breakpoint alley),点一下就能在该行立一个红色断点,再点取消。

6.5.2 函数桩:先搭骨架再填肉

写「主脚本 + 一堆函数」的大程序时,别妄想一次全写完再调试——出了错根本不知道藏在哪。正确姿势是用函数桩(function stubs):先写出所有函数头,函数体暂时放个「占位实现」(随便返回个固定值),让主脚本先跑通,然后一个函数一个函数地实现、调试

function [x, y] = getvals     % 桩:先返回假数据
x = 33;  y = 11;
end
 
function z = calcz(x, y)      % 桩:先随便算个和
z = x + y;
end

主脚本此刻就能执行验证流程对不对,之后逐个把桩替换成真实现。这套「增量式开发」在 C 里一样适用,只是 MATLAB 的脚本环境让迭代更轻快。

6.6 实时脚本与代码单元

6.6.1 实时脚本(Live Script)

R2016a 起 MATLAB 推出了实时脚本(live script):一种 .mlx 格式的富文本文档,可以把文字说明、LaTeX 公式、代码、运行结果(包括图)按小节混排在同一个文档里,图就显示在生成它的代码旁边。

图 6.1:Live Editor(实时编辑器)界面——左侧依次是说明文字、公式 y=x^2\sin(x)、代码段及其绘图结果,文字、公式、代码、图形混排在一个文档中

创建方式:New → Live Script;普通 .m 脚本也可以「另存为 Live Script」。运行时可整体 Run All,也可以点某小节左侧的竖条只跑那一节:

图 6.2:点击小节左侧竖条出现的「Run current section」(运行当前节)按钮——实时脚本可以一节一节地执行,便于分步验证

写完的实时脚本可以导出为 PDF/HTML 分享,也能另存回普通 .m 文件(只保留代码部分,用 type 命令查看时看到的正是代码段)。

6.6.2 代码单元:普通脚本也能分节

买不起新版 MATLAB?普通 .m 脚本也能实现「分节运行」——用 %% 开头的注释行把代码切成若干代码单元(code cells)%% 行的文字就是单元标题:

% sinncosCells.m:画出 sin(x) 和 cos(x)
 
%% 创建向量并绘图
clf
x = 0: 2*pi/40: 2*pi;
plot(x, sin(x), 'ro')
hold on
plot(x, cos(x), 'b+')
 
%% 添加图例、坐标标签和标题
legend('sin', 'cos')
xlabel('x'); ylabel('sin(x) or cos(x)')
title('sin and cos on one graph')

在编辑器里点进某个单元,选「Run Section」只跑这一节(结果保留),或「Run and Advance」跑完跳到下一节。调参数迭代画图时特别顺手——改一节跑一节,不用整个脚本重跑。「Publish」功能还能把整个脚本连同图形发布成 HTML 文档,单元标题自动生成目录,很适合写实验报告。

本章通关标准:

  1. 能写出多返回值函数并用 [a, c] = f(x) 正确接住全部返回值,能解释「只打印不返回」的函数为什么无法参与后续计算。
  2. 能把一个任务拆成「主脚本 + 3 个函数」的模块化结构,并逐条核对函数调用与函数头的参数个数是否匹配;知道子函数是文件私有的辅助函数。
  3. 能说清脚本与函数在作用域上的区别(基础工作区 vs 局部变量),会用 persistent 做调用计数,会用 dbstop 断点抓出 3 < x < 6 这类逻辑错误。