MATLAB
首发于MATLAB
对函数的输入进行检查和解析

对函数的输入进行检查和解析

本文所有内容仅代表本人观点,和MathWorks无关

目录:

  • 为什么要对函数的输入进行检查
  • validateattributes的基本使用
  • validateattributes的额外提示信息
  • validateattributes支持的检查类型和属性
  • validatestring
  • inputParser的基本使用
  • inputParser的可选参数和默认参数值设置
  • inputParser和validateattributes联合使用
  • inputParser的参数名参数值对的设置
  • inputParser解析结构体输入
  • 引子:为什么需要MATLAB的单元测试系统

为什么要对函数的输入进行检查

在工程计算中,如果一个函数的输入有错误,我们总是希望能够尽早的通过对输入的检查,捕捉到这些错误,并及时终止程序。这样做的原因是,如果等到程序运行时出错或者运行结束后计算结果出错再查找,那就很迟了,而且通常debug的成本很高。在多人合作的项目中,如果一个开发人员提供了一个公用的API(应用程序接口)给别人使用,除了要提供说明文档规定输入的格式之外,API内部通常还需要对输入进行彻底的检查,因为开发人员不能保证每个使用者都会仔细地读文档,并且每次都能提供符合规定的数据,作为一个友好的API,一旦输入出了错,API应该及时提示用户,并且帮助诊断错误原因。同理,这样做的原因是,如果要等到程序运行时出错或者运行结束后计算结果出错,不但成本高,而且使用者也许根本无法查出错误的原因。

在MATLAB中,我们可以使用MATLAB提供的专门的函数validateattributes,validatestring和inputParser类来对输入进行检查。它们提供全面的检查功能和清晰的错误提示,是全套的参数检查解析方案。

validateattributes的基本使用

先介绍validateattributes的基本使用。假设在图像处理计算中,我们设计了一个函数叫做processImg ,用来对一张大小是500 x 500 的灰值图像进行处理,计算之前我们需要检查输入是否符合规定,这可以使用validateattributes函数来完成:

% 函数一开始检查输入变量的类型和尺寸
function processImg(img)
  ...
  validateattributes(img,{'numeric'},{'size',[500,500]});
  ... % 函数继续
end

validateattributes的第一个参数img是输入的图像,即要检查的变量;第二个参数是要检查的类型,这里规定img必须是数值类型(numeric);第三个参数对变量要检查的属性,这里的属性是对img规定的尺寸。

validateattributes的最基本调用格式是:

validateattributes(A,classes,attributes)

其中classes和attributes通过元胞数组来指定,并且元胞中可以包括多个要检查的类型和属性,比如我们除了要检查图像的尺寸,还要检查该图像矩阵的值都在0到255之间,可以这样写:

%元胞数组中可以放置多个要检查的属性
...
validateattributes(img,{'numeric'},{'size',[500,500],'>=',0,'<=',255});
...

在这个例子中,要验证的类型是numeric,它是一个各种数值类型的集合,包括int8, int16, int32, int64, uint8, uint16, uint32, uint64,single,和double类型。当然我们可以让类型检查再具体一点,比如做数值积分的时候,我们通常要提供一个积分的网格,比如一维积分中的X轴,而且通常需要保证该x轴格点的值的类型是double,并是单调递增的,可以用validateattributes这样检查:

% 检查数据的类型是double且单增
...
validateattributes(xgrid,{'double'},{'increasing'})
...

validateattributes最少需要三个参数,如果我们只需要检查变量的类型,则第三个参数可以用空的元胞数组来代替。比如写一个阶乘的函数,其输入必须是无符号的整数,除此之外不做之外的其他检查,可以这样写:

% 第三个属性参数为空
...
validateattributes(iA,{'uint8'},{});

数据类型还可以是自定义的MATLAB类,比如下面一个简单的类MyClass

% MyClass
classdef MyClass
   properties
     myprop
   end
end

如果要规定一个函数的输入是该类的对象, 可以这样写

% 要求变量obj是MyClass类的对象且非空
...
validateattributes(obj,{'MyClass'},{nonempty});
...

validateattributes的额外提示信息

前面我们提到,一个友好的API在用户输入出错时,应该提供清晰的诊断信息。以下面这个计算面积的API为例,它接受两个输入,分别是宽和高,计算就是把两者相乘返回:

% 一个简化的计算面积的函数
function A = getArea(width,height)
     A = width*height;
end

显然,输入width和height必须是大于零的数值,所以我们先在函数中添加上validateattributes的基本调用形式:

function A = getArea(width,height)
validateattributes(width,{'numeric'},{'positive'});
validateattributes(height,{'numeric'},{'positive'});
A = width*height;
end

作为测试我们首先要试验该函数的各种合法输入(Positive Test),并且观察结果是否正确;然后还要测试非法的输入(Negative Test),验证函数确实能捕捉到错误,并且给出正确的诊断信息:

% 命令行测试函数功能
>> getArea(10,22)
 ans =
   220

>> getArea(10,0)                   % 如预期捕捉到了错误
 Error using getArea (line 3)
 Expected input to be positive.

>> getArea(0,22)
 Error using getArea (line 2)      % 两个错误信息除了行号,都是一样的
 Expected input to be positive.

到这里我们发现,当第一个参数或者第二个参数不符合规定时,函数确实可以捕捉到错误,
但是提示的错误信息除了行号几乎是一样的(当然我们可以利用行号去检查getArea函数内部,然后发现到底是哪一个参数输入错误了。) ,检查错误还是有些不方便。这里我们可以使用validateattributes它的调用方法,能够提示更清晰的诊断信息,如下所示:

% validateattributes支持额外的诊断信息
function A = getArea(width,height)
   validateattributes(width, {'numeric'},{'positive'},'getArea','width' ,1);
   validateattributes(height,{'numeric'} {'positive'},'getArea','height',2);
A = width*height;                                   %参数4   参数5  参数6
end

其中第4个参数通常提供validateattributes所在函数的名称,第5个参数通常是输入参数的名称,第6个参数表示该参数在整个参数列表中的位置,这样错误的诊断信息就清晰了:

>> getArea(10,0)
Error using getArea
Expected input number 2, height, to be positive.   清楚的说明getArea函数的
Error in getArea (line 3)                          第2个参数不符合规定
validateattributes(height,{'numeric'},{'positive'},'getArea','height',2);

>> getArea(0,22)
Error using getArea
Expected input number 1, width, to be positive.
Error in getArea (line 2)
validateattributes(width,{'numeric'},{'positive'},'getArea','width',1);

总结一下,validateattributes一共支持5种格式,其中后4种支持输出额外的错误诊断信息,这节演示的是第5种,一共有6个参数的格式。

% 一共5种调用方式
validateattributes(A,classes,attributes)
validateattributes(A,classes,attributes,argIndex)
validateattributes(A,classes,attributes,funcName)
validateattributes(A,classes,attributes,funcName,varName)
validateattributes(A,classes,attributes,funcName,varName,argIndex)

validateattributes支持的检查类型和属性

validateattributes可以检查的数据类型:'single','double','int8','int16','int32','int64','uint8','uint16','uint32','uint64','logical','char','struct','cell','function handle','numeric','class name'.

validateattributes可以检查的数据维度属性如下:


validateattributes支持检查的数据的大小范围属性如下:


validateattributes还支持检查的数据其它属性如下:




validatestring

如果要检查的变量恰好是字符串类型,我们可以使用专门做字符串检查的validatestring函数,它接受一个字符串,然后检查该字符串的值是给定的几个可取的值之一。

比如在分析化学计算中,给浓度变量赋值时,我们除了要指定浓度的大小,还要指定单位,我们暂时用字符concentrationUnit来代表浓度(以后还会提到,利用面向对象编程,我们有其它的方式来模拟数值计算中的单位甚至量纲) ,如果我们要限制字符串变量concentrationUnit只取ppm (Parts Per Million)或者ppb (Parts Per Billion), 可以这样使用validatestring:

% validatestring基本用法
...
str = validatestring(concentrationUnit,{'ppm','ppb'});
...

其中第一个参数concentrationUnit是要检查的字符串变量,第二个参数是由所有可取的值构成的元胞字符数组,如果变量concentrationUnit满足条件,那么该调用返回的str是匹配到的字符串.

% command line
>> concentrationUnit= 'ppm';
>> str = validatestring(concentrationUnit,{'ppm','ppb'});
 str =
   ppm   % concentrationUnit匹配了ppm

如果输入的字符变量不匹配字符串元胞中的任何一个,validateattributes将报错,比如:

% command line
>> concentrationUnit= 'pp';
>> str = validatestring(concentrationUnit,{'ppm','ppb'});
  Error
  Expected input to match one of these strings:
  'ppm', 'ppb'
  The input, pp, matched more than one valid string.

和许多MATLAB函数一样,validatestring也支持部分名称(Inexact Name)(不分大小写的部分名称) 。比如我们要验证colorValue字符串只能取red,green,blue,cyan,yellow,magenta这么几个值,validatestring除了接受全名

% 输入是全名
>> colorValue = 'green';
>> str = validatestring(colorValue,       {'red','green','blue','cyan','yellow','magenta'})
 str =
   green

还可以接受不会模棱两可的部分名字,比如

% 输入的名字是Inexact Name
>> colorValue = 'G';
>> str = validatestring(colorValue,{'red','green','blue','cyan','yellow','magenta'})
str =
    green     % G 匹配了green

如果给出的部分名字(Inexact Name)有多于一个的匹配,validatestring则报错

% 匹配必须是独一无二的
>> in = 'color';
>> str = validatestring(in,{'ColorMap','ColorSpace'})
  Expected input to match one of these strings:
  'ColorMap', 'ColorSpace'   %color两个都可以匹配
  The input, color, matched more than one valid string.

inputParser的基本使用

前节所介绍的validateattributes和validatestring是用来验证单个参数的,当一个函数有多个参数,并且允许取默认值时,各种情况的组合就变得复杂起来了,我们可以使用inputParser类来对输入进行解析和检查。下面的几节中,我们将通过不断改进一个求面积的getArea函数,来讲解inputParser的用法。首先,该函数的基本形式是接受宽长两个参数,返回两者的乘积:

% getArea的基本形式
function a = getArea(wd,ht)
   a = wd*ht;
end

我们先用inputParser的基本形式来对函数的两个输入进行解析和检查

% getArea版本1  
function a = getArea(wd,ht)

 p = inputParser;

 p.addRequired('width', @isnumeric); % 检查输入必须是数值型的
 p.addRequired('height',@isnumeric);

 p.parse(wd,ht);

 a = p.Results.width*p.Results.height;  % 从Results处取结果
end

下面在命令行尝试该函数的各种输入,并且检查结果:

% 命令行验证
>> getArea(10,22)
ans =
  220

>> getArea(10)          % 如预期报错 调用少一个参数
Error using getArea
 Not enough input arguments.

>> getArea('10',22)     % 如预期报错 参数width类型错误
Error using getArea (line 8)
 The value of 'width' is invalid. It must satisfy the function:  isnumeric.

下面解释getArea中代码,使用inputParser分成4步

  1. 首先第3行声明一个inputParser的对象,等式右边是inputParser的类名称,也是该类的构造函数。
  2. 第5,6行给Parser对象添加要解析的参数,其中addRequired 是inputParser的一个成员函数。 这里我们添加了两个要解析的参数,名称分别叫做width和height。这些名称和getArea的输入的实参有顺序上的对应关系,但是名称并不一定要完全一样。
  3. 第8行把函数的实参wd,ht提供给inputParser对象,并且进行解析,解析的内容将存放在p.Results中。
  4. 第10行从p.Results中取出解析的结果,计算面积并返回。

inputParser是一个MATLAB类,其UML类图如下:



Figure.5, inputParser类图

这节中我们介绍了addRequired成员方法,下面几节中我们将介绍另外两个成员方法addOptional和addParameter.

inputParser的可选参数和默认参数值设置

在上个版本的函数中,宽和长都是必要的参数,如果只输入一个值,inputParser将提示输入的数目不够

>> getArea(10)
Error using getArea (line 8)
Not enough input arguments.

现在我们希望getArea函数能处理单个参数的情况,比如当计算一个正方形的面积,其实只需要输入一个边长的值就可以了,不需要在重复输入另一个边的数值。也就是说,如果只有一个输入时,函数应该默认我们要计算的是一个正方形的面积,并且把长度取默认的值,即输入的宽度。这要用到inputParser的另一个成员函数,叫做addOptional,示例如下:

 % getArea版本2 
function a = getArea(width,varargin)

  p = inputParser;
  p.addRequired('width',@isnumeric);


  defaultheight = width;                           %取默认值为输入的width
  p.addOptional('height',defaultheight,@isnumeric) %添加height为可选参数

  p.parse(width,varargin{:});

  a = p.Results.width*p.Results.height;
end

这个版本的getArea的语法要点如下:

  1. 第1行中的参数被分成了两个部分,第一个输入width和其余的部分,其余部分的参数被包装在了元胞数组中,后面还会看到更多这样的例子。
  2. 第7行指定了可选参数的默认值。
  3. 第8行给inputParser添加了height作为可选参数

下面在命令行尝试该函数的各种输入,并且检查结果:

% 命令行测试函数功能
>> getArea(10)    % 正确处理的了单个参数的情况
ans =
100

>> getArea(10,22) % 确保仍然可以处理两个参数的情况
ans =
220

inputParser和validateattributes联合使用

inputParser的主要功能是对多个输入参数的解析,其对每个参数的值的检查可以使用匿名函数,
而检查参数的值正是我们前面介绍的validateattributes和validatestring函数的强项,这节中我们把inputParser和validateattributes联合起来使用。

% getArea版本2
function a = getArea(width,varargin)

 p = inputParser;
 p.addRequired('width',@(x)validateattributes(x,{'numeric'},...
 {'nonzero'},'getArea','width',1));


 defaultheight = width;
 p.addOptional('height',defaultheight,@(x)validateattributes(x,   {'numeric'},...
  {'nonzero'},'getArea','height',2));

  p.parse(width,varargin{:});  
  % 注意要把varargin元胞中的内容解开提供给parse函数

  a = p.Results.width*p.Results.height;
end

其中validateattributes使用了validateattributes带额外参数的调用格式。如果调用出错,会提示额外诊断信息。

下面在命令行尝试该函数的各种输入,并且检查结果:

 % 命令行测试函数功能
>> getArea(10,0)  % 如预期检查出第二个参数的错误,并给出提示
Error using getArea (line 37)
The value of 'height' is invalid. Expected input number 2, height, to be nonzero.

>> getArea(0,22)  % 如预期检查出第一个参数的错误,并给出提示
Error using getArea (line 37)
The value of 'width' is invalid. Expected input number 1, width, to be nonzero.

inputParser的参数名参数值对的设置

假设我们还要再给getArea函数添加两个可缺省的参数,它们将作为结果的一部分返回

  • 一个叫做shape,用来表示形状,可取的值是rectangle,square和paralelogram. 其默认值是rectangle。
  • 另一个叫做unit,用来表示输入的单位,可取的值是cm,m,inches,其默认值是inches

在上节的基础上,可以再加入两个addOptional的调用

% getArea版本3
function r = getArea(width,varargin)

 p = inputParser;
 p.addRequired('width',@(x)validateattributes(x,{'numeric'},...
 {'nonzero'}));


 defaultheight = width;
 p.addOptional('height',defaultheight,@(x)validateattributes(x,  {'numeric'},...
 {'nonzero'}));

 defaultshape = 'rectangle';
 p.addOptional('shape',defaultshape,...
 @(x)any(validatestring(x,{'square','rectangle','paralelogram'})));

 defaultunit = 'inches';
 p.addOptional('units',defaultunit,...
 @(x)any(validatestring(x,{'inches','cm','m'})));

 p.parse(width,varargin{:});

 r.area =  p.Results.width*p.Results.height;
 r.shape = p.Results.shape;  %简单起见,shape和unit作为结构体的中的一部分返回
 r.units = p.Results.units;
end

该函数接受如下几种输入,函数的返回值是一个结构体。

% 命令行测试函数功能
>> getArea(10,22,'square')   % 只提供shape
ans =

area: 220
units: 'inches'          % units取默认值
shape: 'square'

>> getArea(10,22,'square','cm')
ans =

area: 220
units: 'cm'
shape: 'square'

这样的设计有2个缺点: (1) 必须得记住第三个和第四参数的顺序,即第三个参数必须是shape,第四参数必须是unit,如果颠倒了inputParser会报错

>> getArea(10,22,'cm','square') % 颠倒了第三和第四个参数
Error using getArea
The value of 'shape' is invalid. Expected input to match one of these strings:
'square', 'rectangle', 'paralelogram'
The input, 'cm', did not match any of the valid strings

(2)如要想给第四个参数提供任何值,必须指定第三个参数的值,尽管第三个参数的值有可能是默认值:

>> getArea(10,22,'rectangle','inches')
ans =             %^该值等于默认值

area: 220
units: 'inches'
shape: 'rectangle'

这里其实第三个参数没有必要提供,以为它等于默认值。归根结底,这是因为两个参数的顺序相对固定,无法更换。

MATLAB的许多函数都不需要记住参数的输入顺序,比如plot函数:

x = 0:pi/10:pi;
y = sin(x) ;
plot(x,y,'color','g', 'LineWidth',2,'MarkerSize',10);

我们可以随意打乱plot的x,y后面的三组参数的顺序,仍然产生同样的图像

plot(x,y,'LineWidth',2,'MarkerSize',10,'color','g');

inputParser中的addParameter成员函数就是用来提供这种功能的,它的使用addOptional几乎是一致的

% getArea版本3:把之前的addOptional都换成addParameter
function a = getArea(width,varargin)

 .....
 p.addParameter('shape',defaultshape,...
 @(x)any(validatestring(x,{'square','rectangle','paralelogram'})));

 ....
 p.addParameter('units',defaultunit,...
 @(x)any(validatestring(x,{'inches','cm','m'})));
 ....
end

addParameter和addOptional的区别是输入的时候,通过addParameter指定的参数必须通过name-value对的形式来赋值。正是因为我们必须指定参数的名称,所以才能自由的变换参数的位置:

% 命令行测试函数功能
>> getArea(10,22,'shape','square','units','m')
ans =            %--name  value  --name   value
area: 220
shape: 'square'
units: 'm'

>> getArea(10,22,'units','m','shape','square')  % 变化了参数的位置
ans =
area: 220
shape: 'square'
units: 'm'


>> getArea(10,22,'units','m')                   % 仅仅提供unit参数
ans =
area: 220
shape: 'rectangle'
units: 'm'

inputParser解析结构体输入

最后顺便提一下,inputParser还可以对结构体的输入进行解析和检查。比如我们要给一个优化函数提供一些运行参数,这些信息可以通过一个configStruct结构体变量传给函数,该结构中包括MaxIter,Tol,StepSize。 在优化函数中,这些计算参数都有各自的默认值,但也可以通过外部指定来重置,这个函数可以这样设计:

% inputParser也可以用来解析结构体
function runProgram(configStruct)

 p = inputParser;

 DefaultMaxIter  = 100  ;  % 计算参数的默认值
 DefaultTol      = 0.001;
 DefaultStepSize = 0.01 ;


 p.addParameter('MaxIter',DefaultMaxIter,
 @(x)validateattributes(x,{'numeric'},{'>',0,'real'}));
 %迭代次数下限
 p.addParameter('Tol',DefaultTol,
 @(x)validateattributes(x,{'numeric'},{'<=',0.01,'real'}));
 %收敛上限
 p.addParameter('StepSize',DefaultStepSize,
 @(x)validateattributes(x,{'numeric'},{'<=',0.01,'real'}));
 %步长上限
 p.parse(configStruct);

 .....
end

我们可以这样在命令行中验证

% 命令行测试函数功能
>> configStruct.MaxIter = 10;
>> configStruct.Tol = 0.001;
>> configStruct.StepSize = 0.01;
>> runProgram(configStruct);


>> configStruct.MaxIter = 10;
>> configStruct.Tol = 0.001;
>>runProgram(configStruct);

引子:为什么需要MATLAB的单元测试系统

前面几节在介绍inputParser类时,我们通过不断的改进getArea函数,使其最终变得更加的友好和完善,我们之前工作流程大致可以概括如下:


Figure.6, 函数更新开发流程1

在这个工作流程中,我们除了改进算法,还在设计完成之后,在命令行中都试了几种典型的调用方式来验证新的函数。这也是实际开发中常见的流程:一边开发一边验证结果。但是随着函数支持越来越多的功能,我们在命令行不但要测试新的调用语法,(包括Positive和Negative的测试)。还要验证以前的调用仍然可以使用,保证新功能的加入没有破坏已有的功能。 这是很重要的一个过程,它保证新的函数或算法是可靠的向后兼容的。所以其实工作流程图 Section 还要修改,还要添加对新的函数进行旧的测试,所以更完善可靠的工作流程应该是图 Section ,每次都要把已经有的测试都检验一遍


Figure.7, 函数更新开发流程2

总结下来,实践中在改进函数和增加新功能的同时,我们需要在添加新的测试的同时,
不断的重复已有测试。这些测试包括正向测试(Positive Test),也包括错误测试(Negative Test)。显然在命令行中不停的重复这样的工作效率很低,那么很自然的问题就是这些测试该如何的组织。
非常直觉的方法是,我们可以把这些测试放到一个测试脚本文件中,每次给函数或者计算增加新功能的时候就运行一遍这个脚本,保证结果没有变化,如果有变化,按实际情况修改函数或者修改测试。添加新功能的时候也要往这个脚本中添加新的测试。基本的工作流程应该如下:



Figure.8 函数和函数测试共生的模式

在这个测试模块中,不但要包括正向测试,还要包括错误测试情况,即要保证函数能够如预期的处理非法输入,抛出错误,一个简单的原始的方法是使用try catch。

测试模块还应该这样的功能:比如测试脚本中有10个测试点,如果第二个测试错误就退出了,那么这个脚本的运行也就结束了,直到我们解决了第二个测试点的问题,脚本才能继续向下运行,最好有这样一个功能,使得一个测试点的错误不影响其它测试点的运行,等到测试结束之后,生成一个报告告诉用户都是哪几个测试通过了,哪几个测试没有通过,这样方便用户一次性解决所有的问题。

最后这节阐释了在一个可靠的科学工程计算中为什么需要一个测试模块,并且一个测试模块该满足哪些基本的要求。其实这里讨论的功能和工作流程,正是MATLAB的单元测试所提供的解决方案。MATLAB的单元测试系统是任何一个大型的MATLAB工程项目中不可缺少的一个组成部分。我们将在后面的章节中详细介绍。


作者简介:

MathWorks开发部MATLAB架构C++高级软件工程师。计算物理学博士,研究方向为电子结构计算、密度泛函算法开发;计算机硕士,研究方向为图像处理。2004年,开始使用MATLAB,在科研编程中遇到了开发大型程序难以维护的困难,花了很多时间用于改进程序但效果总不尽如人意。2009年,开始使用MATLAB面向对象编程,发现工程进度被迅速加快,于是萌生了写一本介绍MATLAB 面向对象编程书的念头。2011年,在美国取得博士学位之后入职MathWorks,从理科科研工作者和多年的MATLAB爱好者,成为一名MATLAB语言的设计开发和实现的软件工程师。2016年,作者在MATLAB中文论坛开辟了技术专栏,和大家分享最新的行业应用技术和MATLAB编程理念,旨在推动软件工程中的现代手段在MATLAB科学工程计算项目中的使用,帮助科学家和工程师们更有效地解决复杂的科研问题。《MATLAB面向对象编程:从入门到设计模式(第二版)》 凝结了作者多年的科研和工作经验以及对MATLAB语言的理解,希望能对各种规模的科学工程计算项目的MATLAB使用者有所启发。

编辑于 2018-03-01

文章被以下专栏收录