Articles Automate Restorable Operations with Custom Managed Records by Erik van Bilsen

emailx45

Местный
Регистрация
5 Май 2008
Сообщения
3,571
Реакции
2,438
Credits
573
Automate Restorable Operations with Custom Managed Records
Erik van Bilsen - August 3, 2020
[SHOWTOGROUPS=4,20,22]
There are those common try..finally blocks that you write over and over again. For example entering and leaving a critical section to protect resources from multiple threads, or calling BeginUpdate and EndUpdate to efficiently bulk-update a FireMonkey control. These kinds of “restorable” operations can be automated with the new Custom Managed Records feature introduced in Delphi 10.4.

autorestorerecords.png


About Custom Managed Records
If you are not yet familiar with Custom Managed Records (called CMRs from here on out), then take a look at my previous post about using CMRs to wrap C(++) APIs.

As a short recap, consider the following CMR:

1
2
3
4
5
6
7
8
type
TFoo = record
public
class operator Initialize(out ADest: TFoo);
class operator Finalize(var ADest: TFoo);
class operator Assign(var ADest: TFoo;
const [ref] ASrc: TFoo);
end;
Then Delphi will automatically call the Initialize operator when you declare such a record, and Finalize when the record goes out of scope. The Assign operator is called to copy one record to another one. You don’t have to use all 3 operators, but you need at least one to create a CMR.

Now, when you write the following code:

1
2
3
4
begin
var Foo1: TFoo;
var Foo2 := Foo1;
end;
Then Delphi will convert this to the following code behind the scenes (note that this code does not compile though):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
begin
var Foo1, Foo2: TFoo;
TFoo.Initialize(Foo1);
try
TFoo.Initialize(Foo2);
try
TFoo.Assign(Foo2, Foo1);
finally
TFoo.Finalize(Foo2);
end;
finally
TFoo.Finalize(Foo1);
end;
end;
Delphi will automatically insert try..finally statements to make sure that the Finalize operator is always called. This is the key to automate “restorable” operations.

For more information about Custom Managed Records, take a look at the Для просмотра ссылки Войди или Зарегистрируйся, my previous post or other resources on the internet.

The remainder of this post shows 3 ways you can use CMRs to automate restorable operations.

1. Automatic BeginUpdate and EndUpdate
Lets start with a simple example: All FireMonkey controls have BeginUpdate and EndUpdate methods that you should call to efficiently bulk-update a control. When methods like these come in pairs, you instinctively use a try..finally block to ensure that EndUpdate is always called:

1
2
3
4
5
6
7
ListView.BeginUpdate;
try
ListView.Items.Add.Text := 'Item 1';
ListView.Items.Add.Text := 'Item 2';
finally
ListView.EndUpdate;
end;
Whenever you see patterns like this, there is a potential for automating this with a CMR:

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
29
30
31
32
33
34
35
36
37
type
TAutoUpdate = record
private
FControl: TControl; // Reference
public
constructor Create(const AControl: TControl);
public
class operator Initialize(out ADest: TAutoUpdate);
class operator Finalize(var ADest: TAutoUpdate);
class operator Assign(var ADest: TAutoUpdate;
const [ref] ASrc: TAutoUpdate);
end;

constructor TAutoUpdate.Create(const AControl: TControl);
begin
Assert(Assigned(AControl));
FControl := AControl;
FControl.BeginUpdate;
end;

class operator TAutoUpdate.Initialize(out ADest: TAutoUpdate);
begin
ADest.FControl := nil;
end;

class operator TAutoUpdate.Finalize(var ADest: TAutoUpdate);
begin
if (ADest.FControl <> nil) then
ADest.FControl.EndUpdate;
end;

class operator TAutoUpdate.Assign(var ADest: TAutoUpdate;
const [ref] ASrc: TAutoUpdate);
begin
raise EInvalidOperation.Create(
'TAutoUpdate records cannot be copied')
end;
Some notes:

  • The Initialize operator just clears the reference to the control. This is a very common pattern that you see a lot with CMRs. Note that you don’t have to add this operator, but we discuss below why you should.
  • You pass the control you want to auto-update to the constructor. The constructor immediately calls BeginUpdate on it.
  • The Finalize operator is called in the finally section of the hidden try..finally block, so this is where you call EndUpdate. You have to check if the control is assigned first though, since it can be nil if the constructor hasn’t been called.
  • And finally, these kinds of CMRs should not be copyable (which is explained below). So we raise an exception in the Assign operator if you try to copy it anyway.
We can now rewrite the example snippet like this:

1
2
3
var AutoUpdate := TAutoUpdate.Create(ListView);
ListView.Items.Add.Text := 'Item 1';
ListView.Items.Add.Text := 'Item 2';
You just create the auto-update CMR, which will immediately call BeginUpdate. Then when the CMR goes out of scope (at the end of the method), the EndUpdate method will automatically be called because of the hidden try..finally block and Finalize operator.

This CMR saves 4 lines of code every time you use it, which I think is pretty great (unless you get paid by the line of course).

CMRs work great with inline variables, as in this example. When you declare the AutoUpdate variable as a “normal” local variable, then the Initialize operator will be called as soon as you enter the method. When using an inline variable instead, the Initialize operator will be called at location where you declare the inline variable, which gives you more control.

These kind of CMRs are very common in C++ (although they are not called CMR in C++). In C++, constructors and destructors are called automatically when an object/struct is declared on the stack. And C++ has copy constructors as well, which are similar to Assign operators in Delphi.

[/SHOWTOGROUPS]
 

emailx45

Местный
Регистрация
5 Май 2008
Сообщения
3,571
Реакции
2,438
Credits
573
[SHOWTOGROUPS=4,20,22]
Why the Initialize operator?
So what happens if you don’t write an Initialize operator? In that case, the CMR will not be initialized at all (also not by the compiler). That means that the FControl field in the sample CMR contains random data. This should’t be an issue if you always call the constructor, but what happens if you don’t?

1
2
3
begin
var AutoUpdate: TAutoUpdate;
end;
In this example, you just declare the CMR without constructing it. If there was no Initialize operator, then its FControl field would contain random data. Then, when the CMR goes out of scope, the Finalize operator gets called, which calls the EndUpdate method on an invalid object, most likely resulting in an access violation.

Why the Assign operator?
As I mentioned above, these kinds of CMRs should not be copyable. But why? Suppose you have the following code:

1
2
3
4
var AutoUpdate := TAutoUpdate.Create(ListView);
var AutoUpdateCopy := AutoUpdate;
ListView.Items.Add.Text := 'Item 1';
ListView.Items.Add.Text := 'Item 2';
This highlighted line in this example will call the Assign operator if available, or otherwise perform a regular record copy. Now, when the copy goes out of scope, its Finalize operator is called, which calls EndUpdate. Then when the original goes out of scope, EndUpdate is called again, resulting in an unbalanced BeginUpdate/EndUpdate pair.

Fortunately, there is no reason to copy these kinds of CMRs. To make sure you don’t copy the CMR by mistake, you should write an Assign operator and handle that situation. In this example, I raise an exception if you attempt to make a copy. But you could also handle this situation in different ways. For example, you could set the FControl field of target CMR to nil instead, which would prevent a call to EndUpdate.

2. Automatic Locking
A common design pattern in multi-threading is to enter a lock to protect a resource, then use that resource and finally release the lock again. Of course, you wrap this inside a try..finally block to ensure that the lock is always released. Consider this simple locked list:

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
29
30
31
32
33
34
type
TLockedList<T> = class
private
FList: TList<T>;
FLock: TSynchroObject;
public
constructor Create;
destructor Destroy; override;
procedure Add(const AItem: T);
end;

constructor TLockedList<T>.Create;
begin
inherited;
FList := TList<T>.Create;
FLock := TCriticalSection.Create;
end;

destructor TLockedList<T>.Destroy;
begin
FLock.Free;
FList.Free;
inherited;
end;

procedure TLockedList<T>.Add(const AItem: T);
begin
FLock.Acquire;
try
FList.Add(AItem);
finally
FLock.Release;
end;
end;
The Add method could take advantage of a CMR:

1
2
3
4
5
procedure TLockedList<T>.Add(const AItem: T);
begin
var AutoLock := TAutoLock.Create(FLock);
FList.Add(AItem);
end;
And the CMR could look like this:

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
29
30
31
32
33
34
35
36
37
type
TAutoLock = record
private
FSyncObj: TSynchroObject; // Reference
public
constructor Create(const ASyncObj: TSynchroObject);
public
class operator Initialize(out ADest: TAutoLock);
class operator Finalize(var ADest: TAutoLock);
class operator Assign(var ADest: TAutoLock;
const [ref] ASrc: TAutoLock);
end;

constructor TAutoLock.Create(const ASyncObj: TSynchroObject);
begin
Assert(Assigned(ASyncObj));
FSyncObj := ASyncObj;
FSyncObj.Acquire;
end;

class operator TAutoLock.Initialize(out ADest: TAutoLock);
begin
ADest.FSyncObj := nil;
end;

class operator TAutoLock.Finalize(var ADest: TAutoLock);
begin
if (ADest.FSyncObj <> nil) then
ADest.FSyncObj.Release;
end;

class operator TAutoLock.Assign(var ADest: TAutoLock;
const [ref] ASrc: TAutoLock);
begin
raise EInvalidOperation.Create(
'TAutoLock records cannot be copied');
end;
This CMR is very similar to the TAutoUpdate CMR presented earlier, so I won’t go into the details here. Note that you can use it with any type of synchronization object (critical section, mutex, etc…).

This CMR is so common in C++, that it is part of the standard library under the name std::lock_guard.
3. Automatic Object Destruction
So what about the obvious use case: automatic object destruction? You can certainly use a similar CMR for this:

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
29
30
31
32
33
34
type
TAutoFree = record
private
FInstance: TObject; // Reference
public
constructor Create(const AInstance: TObject);
class operator Initialize(out ADest: TAutoFree);
class operator Finalize(var ADest: TAutoFree);
class operator Assign(var ADest: TAutoFree;
const [ref] ASrc: TAutoFree);
end;

constructor TAutoFree.Create(const AInstance: TObject);
begin
Assert(Assigned(AInstance));
FInstance := AInstance;
end;

class operator TAutoFree.Initialize(out ADest: TAutoFree);
begin
ADest.FInstance := nil;
end;

class operator TAutoFree.Finalize(var ADest: TAutoFree);
begin
ADest.FInstance.Free;
end;

class operator TAutoFree.Assign(var ADest: TAutoFree;
const [ref] ASrc: TAutoFree);
begin
raise EInvalidOperation.Create(
'TAutoFree records cannot be copied')
end;
This is similar to an std::unique_ptr in C++
You can use it like this:

1
2
3
4
5
6
begin
var Writer := TStreamWriter.Create('foo.txt');
var AutoFree := TAutoFree.Create(Writer);
Writer.WriteLine('Some Text');
OpenFile('foo.txt');
end;
This example has a problem though: the TStreamWriter object will automatically be destroyed when the AutoFree CMR goes out of scope. This is at the end of the method, after the OpenFile call. This means that OpenFile call may fail to open the file because the stream writer still has an exclusive lock on it. With the help of inline variables though, we can scope the AutoFree CMR and ensure that the stream writer is destroyed before the file is opened:

1
2
3
4
5
6
7
8
begin
var Writer := TStreamWriter.Create('foo.txt');
begin
var AutoFree := TAutoFree.Create(Writer);
Writer.WriteLine('Some Text');
end;
OpenFile('foo.txt');
end;
Now, the AutoFree CMR goes out of scope before OpenFile is called. Note that you can also put the stream writer construction inside the inner scope.

While this CMR can be useful, it cannot be used to automatically manage the lifetime of an object in situations where the object is shared. For those situations, you would traditionally use object interfaces to take advantage of automatic reference counting.

But with CMRs, there is an alternative now: a CMR can be used to add automatic reference counting to regular objects.

Which would be similar to an std::shared_ptr in C++
And that is were we enter the subject of “smart pointers”, which is outside the scope of this article, but could be come the subject of a future article…

[/SHOWTOGROUPS]