随着代码越来越复杂,我实在是无法忍受修改完全面向过程的混乱程序了,急需引入面向对象的语法进行重构。 MATLAB面向对象的笔记主要参考的是《MATLAB面向对象编程——从入门到设计模式》(徐潇,李远),书中使用的估计是2015左右的版本。

MATLAB的面向对象语法从整体上看,既不像C++和java那样严格,也没有Python那样过于灵活,而是具有自身的特点。

虽然面向对象机制不可避免地会带来一些运算效率的损失,但是我认为这是值得的,只是需要避免在涉及大量计算的性能瓶颈中使用,对于一些辅助的部分,使用面向对象所带来的简化还是非常舒服的。

简单示例

从最简单的一个自定义类型开始

point2d.m
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
classdef point2d
properties
x
y
end

methods
function obj = point2d(x0,y0)
if nargin == 0
obj.x = 0;
obj.y = 0;
elseif nargin == 2
obj.x = x0;
obj.y = y0;
else
error("unsupported input arguments")
end
end

function obj = normalize(obj)
r = sqrt(obj.x^2+obj.y^2);
obj.x = obj.x/r;
obj.y = obj.y/r;
end
end
end

这里我们定义的类型称为值类型(全值类),与之相对的是句柄类型,在写法上需要对第一行进行改动(< handle代表继承自 handle 类型)

1
2
3
classdef point2d < handle
% ...
end

两种自定义类型的区别主要体现在内存管理中的深拷贝或浅拷贝行为,具体讨论见下文。

MATLAB对于类的方法,在写法上与Python类似,两者在方法中不会省略对象参数,Python习惯使用self表示对象参数,而MATLAB习惯上使用obj表示对象参数。

属性

属性默认值

MATLAB可以给类的属性提供默认值

1
2
3
4
5
6
7
8
classdef point2d
properties
x = cos(pi/12);
y = sin(pi/12);
end

...
end

默认值甚至不需要是常量,可以是任何表达式,但是注意为属性生成默认值的时机是在类被MATLAB加载时,而不是每一个对象创建时,例如

1
2
3
4
5
6
7
classdef demo
properties
time_stamp = date;
end

...
end

常量属性

MATLAB支持给类的属性标记为常量(只读),即不允许对其进行修改(无论是在类的内部还是外部),尝试修改会报错。

1
2
3
4
5
6
7
classdef demo
properties(Constant)
R = pi/180;
end

...
end

对常量属性的赋值发生在类的定义加载时。 如果不给常量属性提供默认值的话,那么它就是[],也就是空的double矩阵。

常量属性实际上是和类绑定的,而不是和对象绑定的。 我们可以通过类名访问,也可以通过对象名访问,但是无论如何都不能进行修改

1
2
3
4
5
6
disp(demo.R)

s = demo();
disp(s.R)

s.R = 100; % 报错

我又想吐槽MATLAB了,所有的语法错误都必须在运行时才会暴露出来,对于C++为代表的编译型语言,我们可以让更多的错误在编译期提前暴露,对于MATLAB则无能为力。

隐藏属性

在默认情况下,通过disp可以查看自定义类型对象的所有属性,但是我们有时希望隐藏部分实现细节,可以使用Hidden实现

1
2
3
4
5
6
7
8
classdef demo
properties
x
end
properties(Hidden)
y
end
end

此时调用disp会只显示属性x,不会显示被隐藏的属性y

1
2
3
4
5
6
>> s = demo();
>> s
s =
demo with properties:
x: []
>>

Hidden只是让用户无法直接地看到这个属性,与属性对应的访问权限无关,如果我们已经知道隐藏属性的名称,仍然可以正常地访问。

方法

构造方法

与类同名的方法称为构造方法,构造方法是唯一的创建自定义类型对象的方式,只能有一个返回值以返回当前对象。

MATLAB只允许创建一个构造方法,但是我们可以使用nargin判断参数个数,并据此执行不同的创建行为,例如

1
2
3
4
5
6
7
8
9
10
11
function obj = point2d(x0,y0)
if nargin == 0
obj.x = 0;
obj.y = 0;
elseif nargin == 2
obj.x = x0;
obj.y = y0;
else
error("unsupported input arguments")
end
end

创建一个变量例如

1
2
3
4
s1 = point2d(1,2);

s2 = point2d();
s3 = point2d;

在无参数构造时,仍然可以省略括号(),但是非常不建议使用,因为这种做法的可读性太低了。

如果我们没有提供构造方法,那么MATLAB会给我们自动提供一个无参数的默认构造方法,使我们可以直接创建对象,除此之外没有具体的行为。 对于自动生成的默认构造方法,我们也无法向其传递任何参数。

一旦我们提供了构造方法,MATLAB就不会为我们提供默认构造方法。如果我们提供的构造方法不支持无参数调用(即没有处理nargin=0的情形),那么这个类型就不支持默认构造了。由于某些涉及自定义类型的命令(例如methods)会尝试无参数创建一个临时对象,在这种情况下使用可能会报错,因此最好支持无参数构造方式。

与构造方法对应的当然是析构方法,但是MATLAB作为一个有强大运行时和内存管理机制的语言,对析构方法的处理要比构造方法更加复杂,留在后文中讨论。

方法的调用

除了上面提到的构造方法,其他方法的第一个参数习惯上都是对象自身,并且使用obj表示,因为obj参数是显式存在的,在处理nargin时需要注意。

对于类的方法我们可以通过对象来调用,此时对象会自动作为第一个参数obj被传入,其他参数再依次传入,例如

1
2
3
4
5
6
7
8
9
10
11
classdef demo
properties
x = 100;
end

methods
function z = compute(obj,y)
z = obj.x + y;
end
end
end

调用例如

1
2
s = demo();
s.compute(10); % 110

我们还可以使用与之等价的普通函数调用方式

1
compute(s,20); % 120

注意:这里和Python不同,以普通函数方式调用时,我们不需要也不允许在前面加上类名,MATLAB有自己内部的分派调度器去判定需要调用哪一个普通函数或者哪一个类型的方法,即使它们是同名的。通常MATLAB会优先选择调用用户自定义类型对应的方法,而不是MATLAB的内置函数。

重写disp方法

对于内置的数据类型,MATLAB经常会使用disp查看变量信息,它对于自定义类型也是支持的,但是默认信息有时过于繁琐,我们可以定制自定义类型调用disp函数的行为,例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
classdef point2d
properties
x = 0
y = 0
end

properties(Constant)
R = pi/180;
end

methods
function disp(obj)
fprintf("(%f,%f)",obj.x,obj.y);
end
end
end

在默认情况下调用disp的输出为

1
2
3
4
5
6
7
>> s = point2d();
>> disp(s)
point2d with properties:

x: 0
y: 0
R: 0.0175

在重写了disp之后的输出为

1
2
>> disp(s)
(0.000000,0.000000)

set和get方法

对于自定义类型的属性,在默认情况下我们可以直接对其进行读写,但是一个很常见的需求是在读写属性时进行控制,MATLAB对句柄类型提供了特殊的setget方法作为隐藏的中间层:如果我们对某个属性定义了对应的setget方法,那么在我们读写某个属性时,实际上是执行了对应的setget方法。

例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
classdef demo
properties
x = 100;
end

methods
function obj = set.x(obj,x1)
if x1 >=0
obj.x = x1;
else
error("x1 must be positive")
end
end

function x1 = get.x(obj)
x1 = obj.x;

disp("call get.x")
end
end
end

对属性x的赋值尝试会通过set方法检查:只有正值才能成功赋值,负值会报错

1
2
3
>> s = demo();
>> s.x = 200;
>> s.x = -10; % 报错

对属性x的读取会通过get方法进行

1
2
3
4
5
>> s = demo();
>> s.x
call get.x
ans =
100

注意:

  • 对于全值类,set方法要求返回obj自身(即set方法的首个参数),否则语法报错,对句柄类则不需要返回值;
  • set方法内部对属性的赋值不会调用set方法;(否则会陷入循环)
  • 在复制整个对象时不会调用set方法;
  • 在给属性提供默认值时不会调用set方法;
  • load过程中,会调用set方法;
  • 如果赋的新值和原有值相同,(通常情况下)也不会调用set方法;

考虑这样的情景:一个属性实时依赖于对其他属性的计算,我们可以定制这个属性的get方法来对外界伪装这个属性的存在,但是在内部不为其分配内存空间,将这个属性标记为非独立属性(Dependent)可以做到这一点,例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
classdef demo
properties
x
y
end
properties(Dependent)
r
end
methods
function r = get.r(obj)
r = sqrt(obj.x^2+obj.y^2);
end
end
end

这样的非独立属性必然是只读的,我们无法对其进行修改,因此提供get方法就足够了。

Static方法

与C++一样,MATLAB也提供了静态方法,静态方法不需要obj参数,既可以通过对象调用,也可以直接通过类名调用。例如

1
2
3
4
5
6
7
classdef demo
methods(Static)
function hello()
disp("hello,world!")
end
end
end

使用例如

1
2
3
4
>> demo.hello()
hello,world!
>> s = demo();s.hello()
hello,world!

静态方法不绑定任何的对象,不能访问类对象的普通属性,但是可以访问类的常量属性。

MATLAB 没有提供类的静态属性,可以使用其它方法替代实现。

类的继承

MATLAB 使用 < 表示继承关系,例如基类demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
classdef demo
properties
Value
end

methods
function obj = demo(val)
if nargin > 0
obj.Value = val;
else
obj.Value = 0;
end
end

function displayValue(obj)
disp(['Value: ', num2str(obj.Value)]);
end
end
end

继承类demo2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
classdef demo2 < demo
properties
ExtraValue
end

methods
function obj = demo2(val, extraVal)
obj = obj@demo(val); % 调用基类构造方法
if nargin > 1
obj.ExtraValue = extraVal;
else
obj.ExtraValue = 0;
end
end

function displayValue(obj)
displayValue@demo(obj); % 调用基类的同名函数
disp(['Extra Value: ',num2str(obj.ExtraValue)]);
end
end
end

使用例如

1
2
3
4
>> s = demo2(10,100);
>> s.displayValue()
Value: 10
Extra Value: 100

这里需要注意的是:

  • demo2的构造方法中,首先显式调用了基类的构造方法并获取返回的对象:obj = obj@demo(val);
  • demo2的普通方法中,显式调用了基类的同名方法:displayValue@demo(obj);

派生类中可以直接使用基类中的(非private权限的)方法和属性,但是有上面两种特殊情况:

  • 基类的构造方法只能如上面所示在构造方法中用特殊语法调用。
  • 如果派生类重写了基类的同名方法,那么对派生类而言,只有在同名方法内部可以用上面的特殊语法访问到基类的版本,在其他情况下只能访问到派生类的版本。

已知MATLAB是允许多继承的,但是我不清楚它是如何解决菱形继承问题的,也不会在代码中使用多继承,因此直接忽略。

类的权限管理

和C++类似,MATLAB也提供了publicprotectedprivate三类权限说明,在默认情况下所有的属性和方法都是public权限。 我们主要关注属性的权限,方法的权限与之类似,并且更简单。

直接从例子开始

1
2
3
4
5
6
7
8
9
10
11
classdef demo
properties
x1
end
properties(Access = protected)
x2
end
properties(Access = privated)
x3
end
end

这里的三个属性分别具有不同的访问权限:

  • x1具有public权限,通过内部方法和外部都可以直接访问;
  • x2具有protected权限,外部无法访问,通过内部方法可以直接访问,通过子类的方法也可以访问;
  • x3具有private权限,只能通过内部方法访问。

MATLAB对属性还提供了更精细的访问权限,我们可以将其拆分为读权限和写权限,例如

1
2
3
4
5
6
7
8
classdef demo
properties(SetAccess = private)
x1
end
properties(SetAccess = private, GetAccess = protected)
x2
end
end

这里:

  • x1的写权限是private,而读权限仍然是默认的public
  • x2则分别指定了写权限和读权限。

C++的友函数/友类会一次性开放所有的权限,而MATLAB提供的类似机制则更加精细:我们可以指定某个属性对某个类的权限,例如

1
2
3
4
5
classdef demo
properties(SetAccess = {?demomanager})
x1
end
end

这里的{?demomanager}表示特别允许demomanager类的成员函数对其访问,在其他情况下都相当于private

类的文件组织

和C++一样,如果类的方法过于复杂,我们可以在类的定义文件中仅仅包括它的方法声明(不含function关键字),在单独的文件中实现具体的方法。 在这种情况下,MATLAB对文件和文件夹的命名有额外的要求:(以名为demo的类为例)

  • 文件夹名称@demo
  • 类的定义放在与类同名的文件中,例如demo.m
  • 类的方法放在与方法同名的文件中,例如hello.m,由于构造方法与类同名,不允许被单独拆分出去(实际也做不到)

事实上,所有放在@demo文件夹中的函数文件都会被视作demo类的方法,即使它在demo类的定义中没有声明(此时方法的各种权限修饰等都采用默认行为),但是不建议这种做法,因为降低了代码的可读性。

完整示例如下

1
2
3
4
5
6
7
8
9
10
11
% @demo/demo.m
classdef demo
methods
str = hello(obj);
end
end

% @demo/hello.m
function str = hello(obj)
str = "hello, from demo";
end

注意:

  • 和脚本文件、函数文件一样,我们也可以在类文件的classdef ... end之后定义局部函数,在类的方法中可以使用这些局部函数,但是外部无法调用。
  • MATLAB规定:某些特殊方法是不能拆分到单独的文件中的,例如构造方法,析构方法,setget方法,还有static方法。

抽象方法和抽象类

和C++类似,MATLAB也提供了抽象方法和抽象类:

  • 不提供实现,只提供接口的方法称为抽象方法;
  • 含有抽象方法的类称为抽象类。

抽象类不能用于实例化对象,自抽象类继承时,必须实现所有的抽象方法,才能得到可以实例化的派生类。

抽象基类例如

1
2
3
4
5
classdef demo
methods(Abstract)
hello(obj)
end
end

继承自抽象基类的派生类例如

1
2
3
4
5
6
7
classdef demo2 < demo
methods
function hello(obj)
disp("hello,world!")
end
end
end

枚举类型

MATLAB也支持枚举类型,例如

1
2
3
4
5
6
7
classdef ColorType
enumeration
RED
GREEN
BLUE
end
end

使用例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
classdef Demo
properties
color
end

methods
function set.color(obj,color)
if isa(color,'ColorType')
obj.color = color;
else
error('Invalid Color');
end
end

function val = get.color(obj)
switch obj.color
case ColorType.RED
val = 10;
case ColorType.GREEN
val = 11;
case ColorType.BLUE
val = 12;
end
end
end
end

这里通过isa在写入时保证参数合法性,在读出时基于switch遍历所有合法情形,自动转换为整数进行输出。

补充

类定义的更新

我们考虑这样一个问题:对于一个自定义类型,如果我们已经使用它的定义创建了若干变量,它们仍然存在于工作区中,但是我又修改了类的定义,那么原定义下的对象会受到影响吗?原本的对象可能保存了有价值的数据,而类定义的更新很可能会损坏数据。

MATLAB对于类的定义更新所采取的策略是:出现下面的情况之一时,才会主动更新旧定义下的现有对象

  • 使用类的新定义创建新对象;
  • 在命令行中查看旧对象;
  • 访问类的常量属性或static方法;(因为这些对当前类的所有对象是共享的)
  • ...

如果新定义存在语法错误,或者与旧定义存在无法调和的冲突,那么旧定义下的对象可能被损坏。

禁止继承与重写

我们可以使用Sealed关键词(封闭,密封)来禁止一个类被继承,例如

1
2
3
classdef (Sealed) demo
...
end

对于基类中的方法,我们也可以使用Sealed关键词来禁止它被派生类重写,例如

1
2
3
4
5
classdef demo
methods(Sealed)
...
end
end

重载运算符

disp方法类似,MATLAB支持自定义类型对运算符的重载,例如plus对应加法(但是考虑到代码的可读性,并不建议大量使用)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
classdef point2d
properties
x
y
end

methods
function obj = point2d(x0,y0)
if nargin == 0
obj.x = 0;
obj.y = 0;
elseif nargin == 2
obj.x = x0;
obj.y = y0;
else
error("unsupported input arguments")
end
end

function disp(obj)
fprintf('(%f,%f)\n', obj.x, obj.y);
end

function result = plus(obj, other)
result = point2d(obj.x + other.x, obj.y + other.y);
end
end
end
1
2
3
>> point2d(1,0) + point2d(0,2)
ans =
(1.000000,2.000000)

MATLAB 特色内容

与通常的面向对象编程语言不同,MATLAB的面向对象编程还有一些特色内容,至少包括:

  • 自定义类型对象的数组化操作,包括两种思路:
    • 数组的每一个元素都是一个自定义类型的对象;
    • 让自定义类型的属性变成数组,并且让方法兼容数组的相关操作;
  • 事件与响应机制,主要是为MATLAB的GUI编程服务的。

第一个内容其实就是 Structure of Arrays vs Array of Structures 这个问题在MATLAB中的体现,两种做法的性能优劣需要根据具体的需求来分析。

目前这些都用不到,因此暂时忽略了。