MATLAB 学习笔记——4. 脚本与函数
.M 文件
MATLAB 的 .m 文件主要分成两类:
- 脚本文件,不接受输入参数,它们处理工作区中的变量和数据。
- 函数文件,可接受输入参数,并且可以有返回值,内部变量是函数的局部变量。
除此之外,还有面向对象编程所涉及的类文件,这里暂时不做讨论。
MATLAB 对于 .m 文件的文件名有一些特殊要求:
- 文件名允许由字母、数字、下划线(
_)和点(.)组成,注意不包括-和空格; - 文件名必须以字母开头;
- 文件名区分大小写。
实际就是要求文件名也是一个合法的标识符,因为在 MATLAB 中运行一个脚本就是通过在终端输入脚本文件名来完成的。
较新版本的 MATLAB 提供了后缀为
.mlx的实时脚本/函数文件,大致就是对 Jupyter Notebook 的模仿,但是用起来并没有后者那么好用,各种操作不够自然。由于.mlx文件不是纯文本文件,如果我们需要在.m文件和.mlx文件之间转换,必须通过 MATLAB 专门提供的工具进行转换,vscode 等编辑器也并不支持.mlx文件的显示。
脚本文件
载入脚本文件会依次执行所有命令,在重复执行大量命令时,可以整理为一个脚本进行执行。
对于当前目录下的 myfile.m 脚本文件,在命令行窗口可以输入脚本的名称来执行脚本(不含文件后缀),
执行结果会输出到命令行窗口。脚本文件可以访问当前工作区的所有变量,对变量的创建和修改也会留在当前工作区中。
例如,脚本文件
1 | sum=0;n=0; |
执行结果
1 | >> myfile |
除了直接使用脚本名称,还可以通过 run 命令实现
1 | run('myscript.m'); |
为了提供代码的可读性,在脚本中更建议使用这种方式调用其它脚本。
脚本虽然可以通过名称直接调用,但是脚本名称本身并不是一个受保护的标识符,我们可以创建与之同名的变量,对于下文中的函数脚本名称同理。(毫无疑问,这是MATLAB在语法上的失败设计)
函数文件
一个典型的函数文件包括一个与文件名同名的主函数(主函数是全局函数,可以被外部调用)
1 | function [output1, ...,outptn] = func(input1, ... , inputn) |
一个更具体的例子如下
1 | function r = rank(A,tol) |
这里在函数定义行之后,可执行代码或空行之前的注释部分,视作函数文件的帮助语句,可以使用 help rank 查看 rank 函数的帮助。
对于无参数的函数,在定义时可以省略括号,例如
1 | function hello() |
注意:
- 如果函数文件的名称和文件中提供的实际函数名不一致,那么 MATLAB 会发出警告,并且会以文件名为准,不可以通过函数名调用,只能通过文件名调用。
- 只有一个函数的 .M 文件甚至不需要使用
end标记来结束function语句;
局部函数
在函数文件和脚本文件的后面,还可以加上若干个局部函数,局部函数的名称不能和当前文件重名,但是局部函数只能当前文件中被调用,不能被外部调用。
对于局部函数来说,并不存在如 C 语言中在调用之前要添加函数声明的要求,即使函数定义在最后,在前面的语句中仍然可以直接使用。例如
1 | function [x_max,x_min] = max_min_values(x) |
如果当前脚本中的局部函数与外部函数重名,那么局部函数的优先级更高。
在 MATLAB 2024a 之前,如果一个脚本文件中希望定义局部函数,必须要放在脚本文件的最后,否则语法报错,最新版去掉了这种无意义的限制。
嵌套函数
除此之外,MATLAB 还允许函数的嵌套定义,嵌套函数显然只允许在定义的函数内部被调用,定义无需出现在使用之前。
嵌套函数相比于局部函数最大的优势是,它可以直接访问外部函数中的变量(按照引用捕获,类似于全局变量),类似于无须声明的全局变量,例如
1 | function outer_function |
嵌套函数可以直接访问(按照引用捕获)外部函数的变量,但是附带的代价是运行效率的降低。
嵌套函数 vs 局部函数:
- 从追求效率和可读性的角度,更好的做法是使用局部函数替代嵌套函数,将所有需要的变量以函数参数的形式显式传递。
- 从内存占用的角度,如果嵌套函数需要处理的是外部函数中的一个大型数组,那么使用嵌套函数相比于内部函数可以减少大型数组的拷贝,从而减少内存占用。
函数打包
虽然 MATLAB 支持局部函数和嵌套函数,但是仍然改变不了一个函数文件只能对外提供一个可用函数的事实,我们为了代码复用,不得不将很多小函数拆分为单独的文件,这种做法非常不合理。
我们的需求是将若干个小函数打包到一个文件中,并且希望这些函数都对外可见,有两种方案可以做到:
- 返回局部函数的句柄组成的元胞数组;
- 基于面向对象,将所有函数设置为一个类的静态函数。
第一种方案例如:
1 | function funcs = mytools_auto() |
主函数还可以进一步简化,通过内置函数 localfunctions() 自动获取当前的所有局部函数的句柄所组成的元胞数组
1 | function funcs = mytools_auto() |
第二种方案例如:
1 | classdef Tool |
这两种方案各有优劣:
- 前者的使用比较简洁,不需要加上额外的类名,但是需要使用句柄;
- 后者虽然需要加上类名,但是由于不需要使用句柄,更利于代码提示,代码更容易维护。
补充
虽然直接使用命令/使用脚本/使用函数的方式在原理上是等效的,但是从优化角度考虑,这几种做法的效率是存在差异的,
通常的顺序是:命令 < 脚本 < 函数,也就是针对函数的优化是最好的。
函数基础
下面我们关注 MATLAB 中的函数语法,MATLAB 是动态语言,无论是函数参数还是返回值都不存在类型匹配的问题,这即让我们写起来很方便,不需要考虑类型问题,也导致我们很容易出错。
不能直接通过控制台的输入来创建函数,通常需要创建并写入单独的函数文件。
函数的返回值
和 Python 不同,MATLAB 函数不需要使用 return 语句来指定返回值,也不会自动使用最后一个表达式的值作为返回值,必须具体给返回变量赋值,例如
1 | function result = hello() |
return 语句通常不需要出现,它出现的作用是让函数执行提前终止,返回变量此时所存储的结果就是函数的返回值,例如
1 | function result = hello() |
函数也可以无返回值,例如
1 | function displayMessage() |
下面这种写法是等效的,也表明函数没有返回值
1 | function [] = displayMessage() |
函数还可以存在多个返回值,例如
1 | function [a, b, c] = func() |
必须使用对应个数的变量组成的数组来接纳函数的返回值,接收的变量个数通常要匹配返回值个数,例如
1 | [a,b,c] = func() |
注意这里对多个返回值的接收形式,稍微正常的脚本语言都可以写成 a,b,c = func(),但是 MATLAB 不行,省略括号直接报错。
如果接收变量个数多于提供的返回值个数,语法报错。
如果接收的变量个数少于返回值个数,则多余的返回值会直接丢弃,而且这种情况完全不报错!(MATLAB 的语法真离谱,正常语言要么打包作为一个变量接收,要么至少报个错吧)
1 | a = func() |
考虑一个极端情况:函数体内没有对返回变量赋值
1 | function s = func() |
此时函数处于一种薛定谔的状态:函数可以被正常调用,但是由于 s 没有被赋值(不会被赋值 []),如果我们在调用后抛弃返回值,没有任何影响,如果尝试使用返回值,则会导致错误,因为返回值是未定义的,不可以用于赋值或调用。(MATLAB 的语法真离谱)
补充:在脚本文件中也可以使用 return 语句,它的含义为脚本提前结束。
考虑下面这种情况
1 | func2(func1()) % 1 |
这里看起来是直接将 func1() 的返回值传递给了 func2,但是实际上它只获取到了第一个返回值,因此只有一个参数被传递给 func2,显示 1。
持久性变量
在函数调用过程中会创建单独的作用域,除了使用 global 声明并使用全局变量之外,MATLAB 还提供了持久性变量(使用 persistent 声明),相当于 C 语言中的局部静态变量,变量的生命周期与函数调用过程无关,例如
1 | counter() % 1 |
注意这里 MATLAB 并不会像 C++ 一样自动忽略掉持久化变量的重复初始化,因此需要额外的判断保护。
注意 persistent 在多进程并行中会出现问题,只是主进程创建的持久变量,其他进程无法访问。
基于元胞数组打包
我们可以通过元胞数组给函数一次性提供多个参数,例如
1 | % 将参数依次存入元胞数组 |
对于提供多个返回值的函数,如果我们已知返回值个数,也可以使用元胞数组来接收,例如
1 | output = cell(1,3); |
这种打包方式可能会有一些效率上的损失,但是让程序更具有通用性,因为固定个数的参数或返回值会限制程序的灵活性。
函数的特殊变量
考虑到实际代码的简洁性和可维护性,下面列举的涉及参数和返回值的各种花里胡哨的操作都不建议使用。
nargin
函数的实参并不要求和函数定义时的形参严格对应:
- 实参个数可以少于形参个数,此时后续的形参处于未定义状态:不能被调用,但是可以赋值后再使用;
- 实参个数不能多于形参,否则会导致语法错误。
我们可以基于 nargout 在函数体内部获取函数调用方提供的实参个数,注意这并不是函数定义中的形参个数,而是由本次调用决定的,例如
1 | func(1); % Number of inputs: 1 |
通常利用 nargin 来判断实际传递的参数个数,并据此提供形参列表中最后几个参数的默认值;
例如
1 | func(10,20,30) % 60 |
nargin 还有第二种用法:在函数外部使用 nargin 函数可以获取函数定义中列出的形参个数,需要传递函数句柄,例如
1 | nargin(@func) % 4 |
如果函数定义中的形参中出现 varargin,则会将结果变成负数以提示。
nargout
我们可以基于 nargout 在函数体内部获取函数调用方请求的返回值个数,注意这并不是函数定义中的返回值个数,而是由本次调用决定的,例如
1 | a = func(); % Number of outputs: 1 |
这种特殊机制是 MATLAB 在运行时专门提供的,对于一般的编程语言(例如 Python 和 C/C++),在函数体内部是不可能获得调用请求的返回值个数的,除非将请求返回值个数作为参数输入。
在函数体内部可以根据 nargout 来提供合适的返回值,MATLAB 的很多内置函数都利用了这种机制,来动态决定所需返回的内容,例如 SVD 分解 $A = U S V^T$
1 | S = svd(A) |
函数如果返回的值少于 nargout 个,这次调用会报错。
即使函数返回更多的值,仍然只有 nargout 个值是有效的,后几个则注定会被接收方丢弃,因此在函数体内部可以直接跳过。
例如
1 | a = func() % 2 |
nargout 还有第二种用法:在函数外部使用 nargout 函数可以获取函数定义中列出的返回值个数,需要传递函数句柄,例如
1 | nargout(@func) % 3 |
如果函数定义中列出的返回值包括 varargout,则会将结果变成负数以提示。
varargin
除了使用 nargin 来判断实参个数,MATLAB 还提供了类似于 Python 的 *args 的特殊参数:varargin,
它会将所有多余参数打包为一个 $1\times N$ 的元胞数组,如果没有多余参数则保持为空。
语法上要求 varargin 必须是最后一个参数。
例如
1 | func() % 0 |
再例如
1 | func(10) % 0 |
varargout
与 varargin 类似,MATLAB 还提供了打包多个返回值的 varargout,例如
1 | func(0) |
键值对参数
在 R2021a 之前,MATLAB 只能以非常原始的方式提供可选的键值对参数:将键的名称作为字符串或字符数组传递,随后加上对应的值。例如
1 | func(1, 'lotType', 'log') |
对于新版本的 MATLAB,终于支持了类似 Python 等现代语言的键值对参数语法(但是网上很多教程没有更新这部分的知识),例如
1 | func(1, PlotType='log') |
对某些内置函数的使用可以得到简化,尤其是绘图相关的函数,例如
1 | x = 1:10; |
可以改成下面的可读性更高的方式
1 | x = 1:10; |
如果希望自定义函数也支持使用这样的键值对参数,对应的配置则相对复杂,需要用到 inputParser 对输入参数进行解析,示例代码如下(多个键值对不需要遵循先后顺序)
1 | demo(); |
改成基于 arguments 的语法则会简单很多,但是 arguments 要求的版本较高(R2023a)
1 | function demo(opts) |
命令语法 vs. 函数语法
这部分内容参考官方文档中的 选择命令语法或函数语法。
命令式语法
在 MATLAB 中,考虑到命令行操作的方便,以下两种风格的函数调用语句是完全等效的
1 | load durer.mat % Command syntax |
这种等效又称为命令-函数二元性,具体来说:包括自定义函数在内的所有函数都支持以下标准的函数调用语法
1 | [output1, ..., outputM] = functionName(input1, ..., inputN) |
如果需要的参数都是字符数组形式(允许为空),并且不接收返回值,那么可以使用如下的命令式语法
1 | functionName input1 ... inputN |
注意命令式语法的参数和函数名,以及参数之间都需要用空格分隔,并且会被引号包裹后作为字符数组传递给函数,相当于
1 | functionName('input1', ..., 'inputN') |
这可以被视作一种简单的宏。
命令式语句在清理和绘图中被大量使用,例如
1 | clc; |
可以改写为
1 | clc(); |
一个常见的错误用法是在命令式语法中使用变量,例如
1 | A = 123; |
因为这里的 disp A 会被处理为 disp('A'),只会输出字符 A。
与之不同的是,某些函数主要是为了命令操作设计的,因此在函数标准调用中反而需要额外加上引号,例如
1 | load mydata.mat var1 var2 |
在实际的脚本中,建议少用命令式语法。
无参数的函数调用
考虑调用函数时没有参数的情况,此时两种方式会变成
1 | functionName() % Command syntax |
可以将其理解为:MATLAB 在无参数的函数调用时,允许省略函数名后面的括号。
这实在是离了大谱,严重降低了程序的可读性,例如考虑 hello 这个简单语句:
hello可能是在显示一个变量hello的值;hello也可能是在执行一个脚本文件hello.m;hello还可能是在执行一个函数但不传递任何参数hello()。
这不仅降低了程序的可读性,还给函数的传递造成了额外阻碍,使得函数不再是第一公民,必须额外提供一个专门的函数句柄语法。
对于无参数的函数调用,非常不建议省略括号。
多义性问题
命令式调用方式可能与其他语法同时成立,导致多义性语句的出现,例如
1 | ls ./d |
这可能是调用 ls 函数切换目录,也可能是数组 ls 和数组 d 的逐个元素相除。在这种情况下,加上或删去空格都可以影响命令的解析,例如
1 | ls./d |
这里不讨论针对多义性语句的具体判定细则,实际编程中当然要避免这种可能有歧义的语句。
函数句柄
MATLAB 在语法设计上存在明显的坑:
- 函数名称并不是受保护的标识符,我们可以直接创建同名变量
- 无参数调用函数时,可以直接省略括号
()
这导致 MATLAB 无法像 Python 一样把函数像普通变量一样直接赋值,而是需要单独设计函数句柄的语法,使用 @ 可以获取函数句柄,例如对于下面这个简单函数
1 | function result = square(x) |
使用 @ 获取函数句柄并赋值给一个变量,通过这个变量就可以直接调用函数
1 | f = @square; |
但是和函数名不同,直接使用赋值函数句柄的变量 f 本身却不能调用函数,即使这个函数并不需要参数。
函数句柄一旦被获取,就可以和普通变量一样被正常传递,例如在函数参数中传递,起到回调函数的作用
1 | function result = applyFunction(func, x) |
特殊函数
为了避免在简单函数的使用时单独构造 .M 文件的麻烦,可以直接使用内联函数或匿名函数,更推荐使用匿名函数。
匿名函数
匿名函数就相当于 C++ 和 Python 中的 lambda 表达式,需要指定输入参数,无需指定返回值。
例如
1 | >> h=@(x,y) x^2+y^2; |
可以在匿名函数的表达式中自动捕获使用工作区的变量,捕获的语义是在定义时按值捕获,此后外部的修改不会对其产生影响。如果输入参数与工作区变量重名,前者会覆盖后者。
例如
1 | >> a=1; |
匿名函数赋值得到的是一个函数句柄,对于匿名无参函数的调用,是不允许省略括号的。
一种常见的需求是对现有函数进行封装,固定其中的一部分参数,保留剩下的部分参数待定,例如
1 | function result = multiplyAdd(a, b, c) |
这时 newFunc 就变成了一个只接收两个参数的匿名函数。
我们还可以创建多重匿名函数,例如
1 | f = @(a,b,c)@(x) a*x^2+b*x+c; |
可以通过匿名函数实现延迟调用的效果,例如
1 | g = @() f(1,2); |
注意:匿名函数在定义时没有提供返回值,在实现机制上相当于统一视作 varargout,因此不能通过 nargout 获取匿名函数的实际返回值个数。
内联函数
提供几个内联函数的例子即可。
例如
1 | >> f=inline('x+2') |
其中可以使用 x+2,x^2+y 之类的 MATLAB 表达式组成的字符串,MATLAB 会自动推断自变量(默认为 x),然后直接使用。
由于内联函数的定义是基于字符数组解析的,只能作为玩具和演示使用,在实际编程中并不建议使用。
