这一章在干嘛?

第 3、6 章你已经会写「参数个数固定」的普通函数了。这一章把函数玩出花:参数个数可变(varargin/varargout)、函数里套函数(嵌套函数)、一行函数不用建文件(匿名函数)、函数当参数传(函数句柄 + fzero/integral 等实战)、以及函数调用自己(递归)。其中「函数句柄」是 MATLAB 最有特色的概念之一 —— 如果你写过 C 的函数指针,会觉得似曾相识。学完这章,你才算真正摸到 MATLAB「函数是一等公民」的门槛。

10.1 可变数量的参数:varargin 与 varargout

10.2 嵌套函数

10.3 匿名函数与函数句柄

10.4 函数句柄的用法:函数函数与实战

10.5 递归函数

10.1 可变数量的参数:varargin 与 varargout

以前写的函数参数个数都是死的,比如 areacirc(rad) 一进一出。但你看 MATLAB 内置函数:plot 想传几组数据就传几组,max 要么返回最大值要么连位置一起返回 —— 它们怎么做到的?答案是两个内置元胞数组

  • varargin(variable input arguments):装多余输入参数的元胞数组;
  • varargout:装额外输出参数的元胞数组。

为什么必须是元胞数组?因为参数可能是任意类型,只有 cell 能混装。配套两个侦探工具:nargin 返回实际收到的输入参数个数nargout 返回调用方期望的输出参数个数

10.1.1 可变输入参数

求圆面积的函数:默认半径单位是英尺;若传了第二个参数 'i' 则按英寸算(1 英尺 = 12 英寸)。写法一是全部参数塞进 varargin

% areafori.m
function area = areafori(varargin)
% areafori(radius) 或 areafori(radius, 'i')
n = nargin;              % 实际传入的参数个数
radius = varargin{1};    % 注意:元胞数组用花括号取元素!
if n == 2
    unit = varargin{2};
    if unit == 'i'
        radius = radius * 12;
    end
end
area = pi * radius.^2;
end
>> areafori(3)
ans =
   28.2743
>> areafori(1,'i')
ans =
  452.3893

写法二更推荐:确定会传的参数写成普通形参,只有不定部分留给 varargin:

% areafori2.m
function area = areafori2(radius, varargin)
if nargin == 2
    if varargin{1} == 'i'
        radius = radius * 12;
    end
end
area = pi * radius.^2;
end

常见坑:nargin 不是「varargin 里的个数」

nargin 返回的是全部输入参数的个数。在 areafori2(radius, varargin) 里传了 (3,'i') 时,nargin 是 2,而 varargin 里只有 1 个元素(varargin{1}'i')。两者对不上号是新手最常见的逻辑错误来源。

10.1.2 可变输出参数

输出端同理。下面的 typesize 总是返回一个类型字符(标量 's' / 向量 'v' / 矩阵 'm'),是向量就额外返回长度,是矩阵就额外返回行数和列数:

% typesize.m
function [arrtype, varargout] = typesize(inputval)
[r, c] = size(inputval);
if r==1 && c==1
    arrtype = 's';
elseif r==1 || c==1
    arrtype = 'v';
    varargout{1} = length(inputval);
else
    arrtype = 'm';
    varargout{1} = r;
    varargout{2} = c;
end
end
>> [t, len] = typesize(4:6)
t =
v
len =
     3
>> [t, r, c] = typesize([4:6;3:5])
t =
m
r =
     2
c =
     3

问题来了:调用者必须自己知道该接几个变量,接多了直接报错。更优雅的做法是在函数内部用 nargout 探测调用方要几个

% mysize.m —— 仿内置 size:默认返回行、列;若调用方接 3 个变量,再返回元素总数
function [row, col, varargout] = mysize(mat)
[row, col] = size(mat);
if nargout == 3
    varargout{1} = row * col;
end
end
 
>> [r, c] = mysize(eye(3))       % nargout==2,只算行列
>> [r, c, n] = mysize(eye(3))    % nargout==3,多给一个元素总数
n =
     9

和 C 语言对照

C 的 printf... 可变参数 + va_list 解析,思路一样但全靠约定和手工;MATLAB 的 varargin 直接把参数打包成元胞数组,取用更安全。nargout 则是 C 里根本没有的能力 —— C 函数无从知道调用方拿不拿返回值,而 MATLAB 可以据此「按需输出」,这是设计接口函数的大杀器。

再来一个把 nargout 用足的完整例子:输入摄氏温度,调用方接 1 个变量就只转华氏度,接 2 个就顺带转开尔文():

% converttemp.m
function [degreesF, varargout] = converttemp(degreesC)
degreesF = 9/5 * degreesC + 32;
if nargout == 2
    varargout{1} = degreesC + 273.15;
end
end
 
>> df = converttemp(17)
df =
   62.6000
>> [df, dk] = converttemp(17)
df =
   62.6000
dk =
  290.1500

注意细节:摄氏转华氏无条件执行(第一个输出永远存在),开尔文只在 nargout == 2 时才算。反过来,如果调用者期望 2 个输出而函数没给满,MATLAB 会直接报错 —— 所以「必给的参数写进函数头,额外输出交给 varargout + nargout」这条分工线要划清楚。另外配套还有 narginchk/nargoutchk 两个函数,用来校验参数个数是否在合法范围内,写正式接口时值得一用。

10.2 嵌套函数

和循环可以嵌套一样,函数体里也可以再定义函数:外层叫外部函数(outer function),里面的叫内部函数(inner function)。规则只有两条硬性要求:每个函数都必须有 end(和嵌套循环一样层层闭合)。

% nestedvolume.m —— 长方体体积 = 底面积 × 高
function outvol = nestedvolume(len, wid, ht)
outvol = base * ht;        % 调用嵌套函数
    function outbase = base
    % 直接使用外层的 len、wid,无需传参!
    outbase = len * wid;
    end
end
 
>> v = nestedvolume(3,5,7)
v =
   105

最值得注意的就是:base 没有形参,却直接用了外层的 lenwid。这是嵌套函数的核心特性 —— 变量作用域属于最外层函数:外层定义的变量,内层直接可见(相当于隐式共享,省去了传参)。

反过来有几条边界要注意:

  • 输出参数的作用域只在内层函数里outbasebase 内有效,外层想打印它是不行的。
  • 内层函数定义的变量若外层没用到,作用域就只在内层。
  • 嵌套函数在后面 GUI 编程章节会大量出现(回调函数共享界面数据非常方便)。

顺带一提:子函数(subfunction)

同一个 .m 文件里还可以放多个平级的函数 —— 第一个叫主函数,后面的叫子函数,只有本文件内可以调用。子函数之间不共享变量、必须老老实实传参;嵌套函数才享受「免传参共享作用域」的特权,两者别混淆。看一个对比(圆柱计算,主函数调子函数算底面积):

% cylcalcs.m —— 一个文件两个平级函数
function [area, vol] = cylcalcs(rad, ht)
area  = basearea(rad);       % 调子函数:必须显式传 rad
vol   = area * ht;
end
 
function a = basearea(r)     % 子函数:文件外不可见
a = pi * r.^2;
end

如果把 basearea 写成嵌套函数(挪进 cylcalcs 函数体内部),rad 就可以免传直接用。选择标准:辅助逻辑不需要共享外层变量时用子函数(依赖关系一目了然),需要频繁共享中间变量(典型如 GUI 回调)时用嵌套函数。

10.3 匿名函数与函数句柄

匿名函数(anonymous function)是不用建 .m 文件的一行式函数。语法:

句柄变量 = @(参数列表) 函数体

@函数句柄运算符(function handle operator),返回的「句柄」本质上是一个指向函数的引用值 —— 用 C 的话说就是「函数指针变量」:

>> cirarea = @(radius) pi * radius.^2;   % .^ 让它支持向量输入
>> cirarea(4)
ans =
   50.2655
>> cirarea(1:4)          % 向量化,一次算四个半径
ans =
    3.1416   12.5664   28.2743   50.2655
>> class(cirarea)
ans =
function_handle

几个关键行为:

  • 没有参数也必须带括号。定义写 @(),调用写 prtran()。只敲句柄名只会把函数定义显示出来(相当于解引用看内容),不会执行:
>> prtran = @() fprintf('%.2f\n',rand);
>> prtran()
0.95
  • 句柄是普通变量,可以 save 进 MAT 文件反复使用 —— 把常用的换算函数攒成一个 tempconverters.mat,每次开 MATLAB load 一下就是你的个人工具箱:
>> cirarea = @(radius) pi * radius.^2;
>> save anonfns cirarea
>> clear
>> load anonfns
>> cirarea
cirarea =
  @(radius)pi*radius.^2

还有一个匿名函数的高级用法值得一提:定义时「冻结」当前变量的值。句柄记下的不只是表达式,还有定义那一刻它引用到的变量值:

>> k = 3;
>> addk = @(x) x + k;    % k 的当前值 3 被捕获
>> addk(5)
ans =
     8
>> k = 100;              % 之后再改 k
>> addk(5)               % 匿名函数里的 k 还是 3!
ans =
     8

这既是有用的特性(做「参数化模板」),也是隐蔽的坑(指望改外部变量来改函数行为是不行的)。需要动态改参数,就把它作为匿名函数的显式参数传进去。

什么时候用匿名函数?

教材的风格准则:函数体只是一条简单表达式时,就用匿名函数。它省掉一个文件、定义即使用,特别适合做被传入其他函数的「一次性小函数」(下一节就见分晓)。逻辑超过一行、需要分支循环的,老老实实写普通函数。

10.4 函数句柄的用法:函数函数与实战

@ 不仅能造匿名函数,还能给已有函数取句柄(内置函数和自定义函数都行):

>> facth = @factorial;   % 给内置阶乘函数取句柄
>> facth(5)
ans =
   120

只用来代替函数名调用没什么意思。句柄真正的价值是 —— 把函数当参数传给另一个函数。这类「接收函数为输入」的函数叫函数函数(function functions)。

10.4.1 把函数传进函数

下面这个函数把传入的函数在 x = 1:.25:6 上求值并画图:

% fnfnexamp.m
function fnfnexamp(funh)
x = 1:.25:6;
y = funh(x);               % 像调用普通函数一样调用句柄
plot(x,y,'ko')
xlabel('x'); ylabel('fn(x)')
title(func2str(funh))      % func2str:句柄 → 字符串,用作标题
end

注意传法:直接传函数名字会报错,必须传句柄

>> fnfnexamp(sin)      % ✗ 报错:Not enough input arguments.
>> fnfnexamp(@sin)     % ✓ 传入内置函数的句柄
>> fnfnexamp(cirarea)  % ✓ 句柄已在变量里,直接传变量名(别再加 @)

配套的一对转换函数值得记住:func2str 把句柄转成字符串(常用在标题里),str2func 把字符串转回句柄(适合让用户输入函数名再执行)。

通过传入 @sin 句柄绘制的 sin(x) 离散采样点图(x 取 1 到 6)

10.4.2 内置函数函数实战

MATLAB 自带一大票函数函数,最常用的几个:

>> fplot(@sin, [-pi, pi])        % 自动采样画曲线,无需手造 x/y 向量
>> feval(@sin, 3.2)              % feval:执行句柄,等价于 sin(3.2)
ans =
   -0.0584
>> fzero(@cos, 4)                % fzero:在 4 附近找零点
ans =
    4.7124

匿名函数同样可以传进去(句柄已在变量里,不再加 @):

>> f = @(x) 3*x.^2 - 2*x + 5;
>> fplot(f, [-6, 6])             % 一行画出二次函数
>> fzero(f, 1)                   % 还能找它的根
>> integral(f, 0, 1)             % 数值积分也吃句柄

fplot(@sin,[-pi,pi]) 绘制的连续正弦曲线,无需手工构造数据点

这一招在工程计算里无处不在:方程求根(fzero)、数值积分(integral/quad)、常微分方程求解(ode45)、甚至给 sort 指定排序键,全都是「传一个句柄进去」。掌握句柄,就解锁了 MATLAB 的整个数值算法库。

举一个把「匿名函数 + 函数函数」串起来的完整例子 —— 求几何级数 的和。 已知时根本不用循环,一条匿名函数向量化解决:

>> geom = @(r, n) sum(r.^(0:n));     % 向量化的等比求和
>> geom(2, 4)                        % 1+2+4+8+16
ans =
    31

还可以不传 n、每次随机生成(对应教材 Practice 10.1 的 varargin 版本)。而更实际的应用是:把一句物理公式做成匿名函数,直接交给 fzero 求根、交给 integral 积分 —— 比如声音在空气中的速度 (T 为兰氏温度):

>> vsound = @(T) 49.02 * sqrt(T);
>> fplot(vsound, [400, 1000])        % 看曲线走向
>> integral(vsound, 492, 671)        % 直接数值积分,无需写任何文件
ans =
   4.9789e+03

此外还有按元胞数组逐元素执行函数的 cellfun(如 cellfun(@length, cs) 求每个元胞的长度),以及矩阵化的 bsxfun,都是函数函数家族的成员,用到时 doc 一下即可。

10.4.3 计时

给代码计时推荐 timeit(比 tic/toc 更稳健,会多次运行取平均):

>> fh = @() prod(1:10000000);
>> timeit(fh)
ans =
    0.0308

函数跑得太快会触发警告说结果不可信 —— 测量对象太短,计时本身的开销就淹没了它。tic/toc 的用法是成对夹住被测代码:tic 起表,toc 读秒,适合测一段脚本的总耗时;而 timeit 吃的是函数句柄,自动重复多次取统计意义上更稳的单次耗时。测算法性能时优先 timeit,测脚本整体耗时用 tic/toc 更省事。

本章函数类型速查

名称定义方式存放位置何时用
普通函数function ... end独立 .m 文件逻辑复杂、需复用
子函数同文件内第二个 function同一 .m 文件只给主函数用的辅助逻辑
嵌套函数外层函数体内定义外层函数体内需共享外层变量(GUI 回调)
匿名函数@(args) 表达式变量(可存 MAT 文件)一行式小函数、做回调

10.5 递归函数

递归(recursion)就是函数调用自己。任何递归定义必须有两条腿:

  • 一般情况(general case):问题化简为更小的同类问题;
  • 基本情况(base case):直接给出答案,递归到此为止

以阶乘为例,(一般情况),(基本情况)。展开

3! = 3 × 2!          挂起,等 2!
   2! = 2 × 1!       挂起,等 1!
      1! = 1         基本情况,终于能算出数了
   2! = 2 × 1 = 2    回程,倒序逐层结算
3! = 3 × 2 = 6
% fact.m —— 递归求 n!
function facn = fact(n)
if n == 1
    facn = 1;                 % 基本情况:递归的刹车
else
    facn = n * fact(n - 1);   % 一般情况:调用自己
end
end
 
>> fact(5)
ans =
   120
>> factorial(5)               % 与内置函数结果一致
ans =
   120

执行过程:fact(3) 要等 fact(2)fact(2) 要等 fact(1)fact(1) 直接返回 1 之后,挂起的式子倒序逐层结算 —— 和 C 里递归调用栈的行为一模一样(每层调用有自己的局部 nfacn)。

第二个例子更有意思:一个不返回值、只负责打印的递归函数,把句子里的单词倒序输出。思路是 strtok 切下第一个词,先递归处理剩下的部分,递归回来之后再打印当前词 —— 于是最后的词最先打印:

% prtwords.m —— 递归倒序打印句子中的单词
function prtwords(sent)
[word, rest] = strtok(sent);
if ~isempty(rest)
    prtwords(rest);   % 先处理"剩下的句子"(下潜)
end
disp(word)            % 递归回来再打印(回程时打印 → 天然倒序)
end
 
>> prtwords('what does this do')
do
this
does
what

这里的基本情况是 rest 为空 —— 句子切到头了,递归停止。

递归更经典的应用是斐波那契数列(Fibonacci):,之后每项是前两项之和。它的递归定义有两个基本情况、一个一般情况:

% fib.m —— 递归求第 n 个斐波那契数
function f = fib(n)
if n == 0 || n == 1
    f = n;                        % 两个基本情况
else
    f = fib(n-1) + fib(n-2);      % 一般情况:分裂成两次自调用
end
end
 
>> arrayfun(@fib, 0:9)             % 顺便复习:句柄配合内置函数函数
ans =
     0     1     1     2     3     5     8    13    21    34

和阶乘不同,斐波那契每次分裂出次自调用,计算量随 n 指数增长 —— fib(40) 就要卡好几秒,而一个 for 循环瞬间出结果。这是「能迭代就别递归」最直观的反面教材,但它把递归结构表达得清清楚楚,是理解分治和树形结构的敲门砖。

常见坑:没有基本情况,或到不了基本情况

忘写基本情况、或传入的参数让基本情况永远不可达,就是无限递归(infinite recursion)。C 里表现为栈溢出崩溃,MATLAB 则会在递归深度超限时报错。写递归函数时,第一行就先问自己:「什么情况下它不再调用自己?」

能用循环就别用递归

坦白说,阶乘这种教科书例子用 for 循环(或内置 factorial)更快更省内存 —— 递归每层都要压栈。教材明确说:可能时用迭代代替递归。递归的真正价值在那些天然自引用的问题上:树的遍历、分形、快速排序、汉诺塔等。这章只是给你装上这个思维工具。

本章通关标准:

  1. 能写出「必传参数 + varargin」形式的函数头,并用 nargin 做分支;能解释为什么 nargin 数的是全部参数而不是 varargin 里的元素。
  2. 能用 @ 创建匿名函数、把 @sin 或匿名函数句柄传给 fplot/fzero/integral,说清「传函数名报错、传句柄才行」的原因。
  3. 能手写递归阶乘,并准确指出一般情况、基本情况各是哪一行、执行顺序为何是「下去挂着、回来倒序结算」。