现在我们关注一些进阶的内容,这部分内容并不是标准的面向对象编程内容, 而是MATLAB的特殊机制所带来的,在面向对象编程中必须考虑的问题,更具体地说,是由MATLAB内存管理机制所带来的问题。 与Java的垃圾收集机制不同,在MATLAB的内存管理机制中,内存的释放发生在确定的时刻。

句柄类和全值类

在MATLAB中实际上有两种类型:

  • 继承自handle基类的类型,可以称为handle类、句柄类或引用类;
  • 其他的类型,可以称为Value类、全值类。

下面以两个自定义类型作为对比

1
2
3
4
5
6
7
8
9
10
11
classdef demo1
properties
data
end
end

classdef demo2 < handle
properties
data
end
end

在创建对象时两者都需要开辟内存来存储数据属性,但是对象和属性的关系是不一样的:

  • 对于句柄类,对象和属性之间相当于指针指向的关系;
  • 对于全值类,对象和属性之间是彻底的包含关系。

例如下面的赋值语句会触发对象的拷贝行为

1
2
3
>> s1 = demo1(); s2 = demo2();
>> s1new = s1;
>> s2new = s2;

具体行为却不同,我们依次进行讨论。

对于全值类的对象赋值,在逻辑上会进行彻底的深拷贝, 但是MATLAB实际上使用了懒拷贝机制:只在必要时进行拷贝。 这里的深拷贝行为会暂时搁置,直到需要对数据属性修改时才会触发。

对于句柄类的对象的赋值只是浅拷贝:只复制了一个空壳子,新对象和旧对象实际上指向的是同一个数据属性。这带来了如下的效果:

  • 对象赋值后的得到的两个变量,它们的属性实际上指向的是同一块内存空间,因此对数据的修改是相互影响的;
  • 对于存储数据属性的内存,MATLAB实际上单独维护了它的引用计数,赋值操作只会增加和减少某块内存的引用计数,如果引用计数减少为0,自动释放内存。

在赋值的两个句柄类对象之间,数据属性实际是同一份,对数据属性修改会相互影响,可以用下面的代码验证

1
2
3
4
5
6
7
8
>> s2 = demo2();
>> s2new = s2;
>> s2.data = 100;
>> s2new.data
100
>> s2new.data = 1;
>> s2.data
1

对于函数/方法中的参数传递,全值类和句柄类的区别就相当于C++中的按值传递和按引用传递,例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
classdef demo1
properties
data = 0;
end
methods
function update(obj,data)
obj.data = data;
end
end
end

classdef demo2 < handle
properties
data = 0;
end
methods
function update(obj,data)
obj.data = data;
end
end
end

对于全值类的测试如下,可以发现方法中修改的obj是全值类对象的副本,并没有影响到对象自身

1
2
3
4
5
6
7
8
9
>> s1 = demo1()
s1 =
demo1 with properties:
data: 0
>> s1.update(100)
>> s1
s1 =
demo1 with properties:
data: 0

对于句柄类的测试如下,可以发现方法中修改的obj是句柄类对象的引用,影响到对象自身

1
2
3
4
5
6
7
8
9
>> s2 = demo2()
s2 =
demo1 with properties:
data: 0
>> s2.update(100)
>> s2
s2 =
demo1 with properties:
data: 100

如果用句柄类对象作为类属性的默认值,会导致所有的对象实际上共享了这个属性,这在语法上的可读性非常低,建议不要使用这种方式实现类的静态属性。

关于全值类和句柄类小结:

  • 全值类适合于简单的数据,它的行为和内置类型例如matrix等是一致的,即赋值和参数传递都蕴含着深复制的逻辑;
  • 句柄类适合于资源管理和对大型数据的管理,句柄类的赋值和参数传递都蕴含着浅复制的逻辑。
    • 对于资源管理,复制是不合理的;
    • 对于大型数据,我们希望避免很多耗时的复制行为。

链式调用

我们来探究全值类和句柄类对于C++风格链式调用的支持。

首先考虑一个全值类

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

methods
function obj = demo1(n)
obj.n = n;
end

function obj = add(obj,m)
obj.n = obj.n + m;
end

function show(obj)
fprintf('n=%d\n',obj.n);
end
end
end

对于这个全值类的正常使用如下

1
2
3
4
5
a = demo1(1);
a.show(); % n=1

a.add(2);
a.show(); % n=1

这和全值类的逻辑是相符的,即修改不会影响原件。

下面的这些语句是合法的,因为每一个方法都返回了创建或修改后的副本

1
2
demo1(3).show(); % n=3
demo1(4).add(1).show(); % n=5

然后考虑一个句柄类

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

methods
function obj = demo2(n)
obj.n = n;
end

function add_no_return(obj,m)
obj.n = obj.n + m;
end

function obj = add_return(obj,m)
obj.n = obj.n + m;
end

function show(obj)
fprintf('n=%d\n',obj.n);
end
end
end

这里我们提供了两个版本的加法方法,它们都可以达到对原件修改的目的,区别在于有没有返回值。 对于这个句柄类的正常使用如下

1
2
3
4
5
6
7
8
a = demo2(1);
a.show(); % n=1

a.add_no_return(2);
a.show(); % n=3

a.add_return(3);
a.show(); % n=6

这和句柄类的逻辑是相符的,即修改会影响原件。

下面的这些语句是合法的

1
2
3
4
5
6
7
8
9
10
11
12
13
demo2(3).show(); % n=3
demo2(4).add_return(1).show(); % n=5

a = demo2(5).add_return(1);
a.show(); % n=6

b = a.add_return(1);
a.show(); % n=7
b.show(); % n=7

b.add_no_return(1);
a.show(); % n=8
b.show(); % n=8

但是下面的语句却是非法的,因为add_no_return没有返回值

1
2
3
4
demo2(10).add_no_return(1).show(); % error

tmp = demo2(20);
tmp.add_no_return(1).show(); % error

类对象的清除

我们可以使用clear命令清除当前作用域中的某个变量或所有变量(使得对应的标识符恢复到未定义状态),例如

1
2
clear a;
clear; % 清除全部变量

在函数调用等具有独立作用域的过程中,离开作用域也会隐式调用clear清理所有的局部变量。

那么,clear命令作用于一个自定义类型对象会发生什么?

  • 对于全值类的对象,clear会直接清理它以及它的所有数据属性,并释放对应的内存空间
  • 对于句柄类的对象,clear会清理对象自身,但是对于对象的数据属性,只会让其引用计数减一。只有数据的引用计数归零,才会释放相应的内存。

需要注意的是:

  • clear命令无视类型访问权限的约束,对于私有属性的操作并没有什么区别;
  • 如果清理的数据属性是handle类型,MATLAB会在引用计数减为0时,自动调用它的delete方法,然后才会释放内存,见下文。

类对象的析构

我们在前面已经介绍了类的构造方法,但是析构方法的介绍涉及到MATLAB的内存管理机制,不得不拖到后面。

handle基类为我们提供了delete方法(析构方法),delete方法的效果是:无视数据属性的引用计数,强制释放其内存。(与懒拷贝类似,这里只是逻辑上的立刻释放,MATLAB有独立的内存管理机制,不需要用户干涉)

我们可以手动调用delete方法,但这是一个很危险的行为,它会导致所有正在引用这个数据属性的其他对象失效,但是并不会影响变量的标识符,对于失效对象的访问会触发错误。MATLAB提供的内置函数isvalid可以用于判断句柄类对象是否保持在有效状态。

delete方法会被MATLAB在合适的时机自动调用,除非显式进行手动调用,一般情况下对delete的自动调用不会导致无效对象的产生。

对于管理资源的句柄类,我们可以重写继承自handle基类的delete方法,来自定义它的析构行为,例如资源管理

1
2
3
4
5
6
7
8
9
10
classdef demo < handle
properties
fID = fopen('tmp.txt');
end
methods
function delete(obj)
fclose(obj.fID)
end
end
end

与之不同的是,全值类不支持自定义析构,即使我们定义了delete方法,它与其他方法也没什么区别,并且只能手动触发。

类对象的保存和加载

我们可以直接用saveload命令作用于自定义类型的对象,这没有任何问题,但是我们还是需要研究一下其中的细节。

save函数将自定义类型的对象保存到MAT文件时,会保存如下信息:

  • 变量名称
  • 自定义类型名称
  • 自定义类型的所有属性的默认值(如果MAT文件中有多个同一类型对象,那么默认值只会保留一份)
  • 当前对象的所有属性值

不会保存如下信息:

  • 类的完整定义
  • 对象的常量属性
  • 方法中的持久性变量

注意:

  • 由于MAT文件不会保存自定义类型的定义,在重新加载时必须在搜索路径中可以找到这个类的定义文件;
  • 对于句柄类,在保存时建议检查其有效性,对于无效的对象可以进行正常的保存和加载,但是加载得到的仍然是一个无效对象,不含数据。

在加载时我们需要尽量保持类的定义与保存时的定义一致,如果类的新定义和保存时的旧定义不一致,MATLAB会采用如下的措施进行转换,可能丢失数据

  • 如果移除了一个旧属性,则加载时会丢弃对应的值;
  • 如果添加了一个新属性,则加载时会对该属性的值取为新定义中的默认值。

除了默认的保存和加载行为,我们还可以通过定义特殊的saveobj方法和loadobj方法来自定义行为, saveload方法在发现自定义类型提供了对应的saveobj方法和loadobj方法时,会在过程中调用它们。

saveobj方法负责实现的功能是将对象转换为一个简单的结构体数组,save命令会从结构体数组开始,负责将数据以及其他信息存储到文件中。 例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
classdef demo
properties
x
y
z
end
methods
function s = saveobj(obj)
s.x = obj.x;
s.y = obj.y;
s.z = obj.z;
end
end
end

与此同时,我们还需要提供配套的loadobj方法,它的过程是反过来的:接收一个结构体数组,生成一个自定义类型的对象。需要注意的是,loadobj方法必须标记为Static静态方法,因为对象还没创建呢。

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
classdef demo
properties
x
y
z
end
methods
function s = saveobj(obj)
s.x = obj.x;
s.y = obj.y;
s.z = obj.z;
end
end

methods(Static)
function obj = loadobj(s)
if isstruct(s)
obj = demo();
obj.x = s.x;
obj.y = s.y;
obj.z = s.z;
end
end
end
end

这部分内容很有价值:在非常耗时的科学计算中,有必要及时地保存中间结果,例如执行了一定步数后自动保存,在遇到错误或异常中断后,下次可以直接从中间结果开始继续计算。

有时候,一个自定义类型有很多数据属性,但是其中只有很少的一部分是值得保存的,其他部分只是计算的中间产物或辅助变量,如果全部保存下来不仅占用空间过大,而且耗时。 这些不需要被保存到MAT文件中的属性,我们可以将其标记为瞬态属性(Transient),MATLAB在保存过程中会将其自动忽略,在加载后对应的值为空。(这比定制saveobjloadobj更加简便)

例如

1
2
3
4
5
6
7
8
9
10
11
12
classdef demo
properties
x
y
z
end
properties(Transient)
x1
y1
z1
end
end