Delphi 的記憶體管理機制建立在物件的建立和銷毀流程之上,透過 CreateFree 方法控制物件的生命週期。物件建立時,系統分配記憶體並設定參照計數為 1;銷毀時則遞減計數,當計數歸零時釋放記憶體。介面則透過參照計數管理,指定時遞增,銷毀時遞減,確保資源正確釋放。方法呼叫效率可藉由引數傳遞方式和方法內聯來提升,值傳遞確保資料安全,參照傳遞提升效率,而方法內聯則減少函式呼叫開銷。然而,動態陣列和開放陣列的行為存在差異,動態陣列以參照傳遞,即使 const 修飾仍可修改原始陣列;開放陣列則傳遞地址和最高索引,const 修飾有效阻止修改。Slice 函式提供擷取陣列片段的機制,本質上是編譯器處理的特殊語法,傳遞陣列地址和大小。此外,指標允許直接操作記憶體,提升效率,例如在影像處理中,使用指標直接修改畫素值,避免資料複製。在特定情況下,可運用組合語言和 SSE2 指令集進一步最佳化程式碼,例如以組合語言實作向量乘法,或使用 SSE2 指令集處理大型資料緩衝區,大幅提升效能。然而,Win64 編譯器限制方法只能使用純 Pascal 或純組合語言,Win32 編譯器則允許混合使用。建議在使用組合語言時,同時保留 Pascal 版本,並使用預定義的條件編譯符號管理程式碼編譯。

物件的建立和銷毀

在 Delphi 中,物件的建立和銷毀是透過 CreateFree 方法實作的。當建立一個物件時,Delphi 會分配記憶體給該物件,並將其參照計數設為 1。當物件被銷毀時,Delphi 會減少其參照計數,如果參照計數為 0,則會釋放該物件的記憶體。

o1 := TObject.Create;
o2 := o1;
// o1 and o2 now point to the same object
o1.Free;
// o1 and o2 now point to unowned memory
o1 := nil;
// o2 still points to unowned memory

介面的使用

Delphi 的介面是透過參照計數實作的。當建立一個介面時,Delphi 會分配記憶體給該介面,並將其參照計數設為 1。當介面被指定給另一個變數時,Delphi 會增加其參照計數。當介面被銷毀時,Delphi 會減少其參照計數,如果參照計數為 0,則會釋放該介面的記憶體。

var
  i1, i2: IInterface;

begin
  i1 := TInterfacedObject.Create;
  // i1 points to an object with reference count 1
  i2 := i1;
  // both i1 and i2 point to a same object with reference count 2
  i1 := nil;
  // i2 now points to an object with reference count 1
  i2 := nil;
  // reference count dropped to 0 and object is destroyed
end;

方法呼叫最佳化

Delphi 的方法呼叫可以透過最佳化引數傳遞和方法內聯來提高效率。Delphi 支援透過值傳遞和透過參照傳遞兩種方式。透過值傳遞可以避免對原始資料的修改,而透過參照傳遞可以提高效率。

procedure TfrmParamPassing.ProcString(s: string);
begin
  // Enable next line and code will suddenly become
  // much slower!
  // s[1] := 'a';
end;
圖表翻譯:
  flowchart TD
    A[物件建立] --> B[記憶體分配]
    B --> C[參照計數設為 1]
    C --> D[物件銷毀]
    D --> E[參照計數減少]
    E --> F[記憶體釋放]

內容解密:

Delphi 的記憶體管理和最佳化技術是根據參照計數和物件建立和銷毀的原理。透過瞭解這些原理,可以提高程式的效率和可靠性。在實際應用中,需要根據具體情況選擇合適的最佳化技術,以達到最佳的效果。

動態陣列的奇怪行為

在開始這一節之前,我們先來看看一些有趣的結果。當一個引數是動態陣列時,會發生一些奇怪的事情。讓我們先從程式碼開始。

為了測試引數傳遞,程式碼建立了一個包含 100,000 個元素的陣列,設定 arr[1] 元素為 1,並且呼叫 ScanDynArray 10,000 次。ScanDynArray 設定 arr[1] 為 42 並離開。

另一個方法 btnConstDynArrayClick (未顯示) 的工作方式與 btnDynArrayClick 相同,但它呼叫 ScanDynArrayConst 而不是 ScanDynArray

const
  CArraySize = 100000;

procedure TfrmParamPassing.ScanDynArray(
  arr: TArray<Integer>);
begin
  arr[1] := 42;
end;

procedure TfrmParamPassing.ScanDynArrayConst(
  const arr: TArray<Integer>);
begin
  // 編譯器允許這個操作
  arr[1] := 42;
end;

procedure TfrmParamPassing.btnDynArrayClick(Sender: TObject);
var
  arr: TArray<Integer>;
  sw: TStopwatch;
  i: Integer;
begin
  SetLength(arr, CArraySize);
  arr[1] := 1;
  sw := TStopwatch.StartNew;

  for i := 1 to 10000 do
    ScanDynArray(arr);
  sw.Stop;

  ListBox1.Items.Add(Format(
    'TArray<Integer>: %d ms, arr[1] = %d',
    [sw.ElapsedMilliseconds, arr[1]]));
end;

你能夠找到這個例子中的奇怪程式碼嗎?它是 ScanDynArrayConst 方法中的指定陳述式。我們說過 const 字首會防止對引數的修改,但在大多數情況下,它確實如此。動態陣列是例外。

當你傳遞動態陣列給一個方法時,Delphi 將其視為指標。如果你將其標記為 const,沒有什麼會改變,因為只有指標被視為常數,而不是它所指向的資料。這就是你可以透過 const 引數修改原始陣列的原因。

這是第一級的奇怪行為。讓我們進一步探索!

如果你檢視 System 單元,你會看到 TArray<T> 被定義如下:

type
  TArray<T> = array of T;

在理論上,如果我們用 array of Integer 取代 TArray<Integer>,程式碼應該會以相同的方式工作。然而,事實並非如此!

procedure TfrmParamPassing.ScanDynArray2(
  arr: array of integer);
begin
  arr[1] := 42;
end;

procedure TfrmParamPassing.ScanDynArrayConst2(
  const arr: array of integer);
begin
  // 在這種情況下,以下行不會編譯
  // arr[1] := 42;
end;

當我們使用 array of T 語法作為方法引數時,它不代表動態陣列,而是一個開放陣列引數。這種型別的引數資料是在動態陣列引入之前新增到語言中的,並且最初用於接受可變數量引數的方法,例如 Format

對於每個開放陣列引數,編譯器實際上傳遞兩個值給方法:第一個元素的地址和最高元素的索引(與 High 函式傳回的值相同)。索引從 0 開始,就像動態陣列一樣。

警告:代表最高元素索引的第二個引數始終是一個 32 位值。目前,即使使用 64 位編譯器,你也不能使用具有超過 4 GB 元素的開放陣列。

在這種情況下,編譯器不允許我們修改 arr[1] 當引數被標記為 const。另外,在 ScanDynArray2 方法中,程式碼會對陣列進行完整的複製,就像傳遞一個正常的靜態陣列一樣。

如果我們宣告 type TIntArray = array of Integer,然後重寫程式碼以使用這個陣列,我們會得到原始 TArray<T> 行為。陣列始終是透過參照傳遞的,程式碼可以透過 const 引數修改原始陣列。

內容解密:

在這個例子中,我們看到動態陣列和開放陣列引數之間的差異。動態陣列是透過參照傳遞的,而開放陣列引數則是透過值傳遞的。這種差異會導致一些奇怪的行為,例如透過 const 引數修改原始陣列。

圖表翻譯:

  graph LR
  A[動態陣列] -->|傳遞|> B[方法]
  B -->|修改|> A
  C[開放陣列引數] -->|傳遞|> D[方法]
  D -->|不修改|> C

在這個圖表中,我們看到動態陣列和開放陣列引數之間的差異。動態陣列可以透過 const 引數修改原始陣列,而開放陣列引數則不可以。

Delphi 陣列與 Slice 函式

Delphi 是一種強大的程式設計語言,具有豐富的陣列操作功能。在本文中,我們將探討 Delphi 陣列的基本概念、陣列宣告、陣列操作,以及 Slice 函式的使用。

陣列宣告

在 Delphi 中,陣列可以宣告為靜態陣列或動態陣列。靜態陣列的大小在編譯時就已經確定,而動態陣列的大小可以在執行時動態調整。

// 靜態陣列
var
  arr: array[1..10] of Integer;

// 動態陣列
var
  arr: TArray<Integer>;

陣列操作

Delphi 提供了多種陣列操作函式,例如 LowHigh 函式,可以用來取得陣列的最低和最高索引。

var
  arr: array[1..10] of Integer;
  i: Integer;

begin
  for i := Low(arr) to High(arr) do
    arr[i] := i;
end;

Slice 函式

Slice 函式是一種特殊的函式,可以用來傳遞陣列的一部分給另一個函式。它可以讓你傳遞陣列的第一個元素到指定的索引之間的元素。

function SumArr(const arr: array of Integer): Integer;
var
  i: Integer;
begin
  Result := arr[Low(arr)];
  for i := Low(arr) + 1 to High(arr) do
    Inc(Result, arr[i]);
end;

procedure btnArraySliceClick(Sender: TObject);
var
  arr: array[1..10] of Integer;
  i: Integer;
  sum: Integer;
begin
  for i := Low(arr) to High(arr) do
    arr[i] := i;
  sum := SumArr(arr);
  ListBox1.Items.Add(IntToStr(sum));
  sum := SumArr(Slice(arr, 3));
  ListBox1.Items.Add(IntToStr(sum));
end;

Slice 函式的實作

Slice 函式並不是一個真正的函式,而是一種編譯器的黑科技。它只在傳遞陣列給函式時才會被呼叫。當傳遞陣列給函式時,編譯器會傳遞兩個引數:陣列的地址和陣列的大小減一。

// Slice 函式的實作
function Slice(arr: array of Integer; count: Integer): array of Integer;
begin
  // 傳遞陣列的地址和大小減一
  Result := arr[Low(arr)..Low(arr) + count - 1];
end;

方法呼叫最佳化

在瞭解了不同資料型別的傳遞方式後,我們現在來分析一下 ParameterPassing 範例程式的結果。靜態陣列可以以複製(by value)或指標(by reference)的方式傳遞,兩者的速度差異非常明顯(174 毫秒 vs 0 毫秒)。無論是哪種方式,原始陣列元素的值都不會被修改(保持為 1)。

同樣的,當陣列被宣告為開放陣列(如 array of Integerconst array of Integer)時,也會出現相同的情況。

TArray<Integer>TIntArray 的行為完全相同。陣列總是以傳值的方式傳遞,且原始值可以被修改(在記錄檔中顯示為 42)。

記錄(record)可以被複製(by value)或以指標(by reference)的方式傳遞,這兩種方式之間的速度差異為 165 毫秒 vs 51 毫秒。

字串(string)總是以指標的方式傳遞字串資料。當作為正常引數傳遞時,參考計數會被遞增/遞減;而當作為常數引數傳遞時,參考計數不會被修改。這導致了 260 毫秒 vs 46 毫秒的速度差異。類似的效果也可以在介面型別引數中觀察到。

方法內聯(Method Inlining)

在本章前面的部分,我花了相當多的時間描述方法內聯的啟用和停用機制,但我沒有解釋什麼是方法內聯。簡單地說,方法內聯允許一個方法被編譯為另一個方法的一部分。

當你呼叫一個未被標記為可內聯(不具有 inline 關鍵字)的方法時,編譯器會準備方法引數,並執行 CALL 匯編指令。如果被呼叫的方法被標記為可內聯,則編譯器基本上只是將可內聯方法的主體插入到呼叫者方法的程式碼中。這加速了程式的執行,但也使得程式變得更大,因為這種內聯發生在每個呼叫可內聯方法的地方。

程式碼最佳化

讓我們來看一個範例。以下的程式碼來自 Inlining 範例,將一個值遞增 10,000,000 次:

function IncrementInline(value: integer): integer; inline;
begin
  Result := value + 1;
end;

procedure TfrmInlining.Button2Click(Sender: TObject);
var
  value: Integer;
  i: Integer;
begin
  value := 0;
  
  for i := 1 to 10000000 do
    value := IncrementInline(value);
end;

由於 IncrementInline 被標記為內聯(且編譯器設定允許內聯),實際生成的程式碼並不會真正呼叫這個方法 10,000,000 次。程式碼看起來更像下面的例子:

procedure TfrmInlining.Button2Click(Sender: TObject);
var
  value: Integer;
  i: Integer;
begin
  value := 0;
  
  for i := 1 to 10000000 do
    value := value + 1;
end;

如果你執行這個範例,你會看到內聯版本的執行速度遠遠快於非內聯版本。在我的測試電腦上,非內聯版本需要 53 毫秒,而內聯版本只需要 26 毫秒。

當你在同一個單元中呼叫內聯方法時,請確保內聯方法在被呼叫之前已經被實作。否則,編譯器只會默默地將方法當作正常方法對待,並不會生成任何提示。

小提示

fixinsight.asp 中可以找到更多相關資訊。

方法呼叫最佳化

在下一個範例中,方法實際上並沒有被內聯,儘管我們可能期望它會被內聯:

指標的魔力

在 Delphi 中,指標是一種變數,儲存著其他變數、動態分配的記憶體、特定字元在字串中的地址等。指標的大小始終相同,32 位系統上為 4 個位元組,64 位系統上為 8 個位元組。這與 NativeIntNativeUInt 的大小相同,這並不巧合,因為在某些情況下,將指標轉換為整數或反之亦然是很實用的。

讓我們考慮一個 TPoint3 紀錄,它包含了一個三維空間中的點。然後,我們可以使用 ^TPoint3 語法宣告一個指向這種型別的指標,並給這種型別命名為 PPoint3。雖然 TPoint3 的大小為 24 個位元組,但 PPoint3 的大小取決於編譯器的目標(32 位或 64 位),分別為 4 個位元組或 8 個位元組。

type
  TPoint3 = record
    X, Y, Z: Double;
  end;
  PPoint3 = ^TPoint3;

現在,如果我們宣告一個 P3 變數,其型別為 TPoint3,以及一個 PP3 變數,其型別為 PPoint3,我們可以將 PP3 的內容改為 P3 的地址,使用 PP3 := @P3。之後,我們可以使用 PP3^ 語法來參照 PP3 所指向的資料(即 P3)。

Delphi 還允許使用較短的形式來存取指標變數的欄位。我們不需要寫 PP3^.X;簡單的 PP3.X 就足夠了。

var
  P3: TPoint3;
  PP3: PPoint3;

begin
  PP3 := @P3;
  // 現在 PP3.X 等同於 P3.X
end;

這種指標的使用方式可以大大簡化程式碼,並提高效能。尤其是在需要高效率的應用中,指標可以幫助我們直接操作記憶體,減少不必要的複製和轉換。

實際應用:顏色轉換

現在,讓我們回到 Mr. Smith 的問題上。他需要將影像從 BGR 格式轉換為 RGB 格式。使用指標,我們可以直接操作影像資料,實作高效的顏色轉換。

procedure ConvertBGRToRGB(Source: PByte; Dest: PByte; Width, Height: Integer);
var
  X, Y: Integer;
begin
  for Y := 0 to Height - 1 do
  begin
    for X := 0 to Width - 1 do
    begin
      // BGR -> RGB
      Dest[(Y * Width * 3) + (X * 3)] := Source[(Y * Width * 3) + (X * 3) + 2]; // R
      Dest[(Y * Width * 3) + (X * 3) + 1] := Source[(Y * Width * 3) + (X * 3) + 1]; // G
      Dest[(Y * Width * 3) + (X * 3) + 2] := Source[(Y * Width * 3) + (X * 3)]; // B
    end;
  end;
end;

這個例子展示瞭如何使用指標直接操作影像資料,實作高效的顏色轉換。透過使用指標,我們可以避免不必要的資料複製和轉換,從而提高效能。

指標的魔力

在 Delphi 中,指標是一種強大的工具,允許您直接存取和操作記憶體。指標可以用來建立動態資料結構,例如樹和連結串列,也可以用來動態組態記錄。

指標的基本操作

指標可以進行一些基本的運算,例如增加和減少。例如,Inc(ptr) 可以增加指標 ptr 所指向的地址。

var
  pb: PByte;
begin
  pb := pointer(0);
  pb := pb + 1;
  ListBox1.Items.Add(Format('PByte increment = %d', [NativeUInt(pb)]));
end;

指標的數學運算

一些指標型別,例如 PCharPAnsiCharPByte,可以用於基本的數學運算。然而,其他指標型別需要啟用 {$POINTERMATH ON} 編譯器指令才能進行數學運算。

var
  pi: PInteger;
begin
  pi := pointer(0);
  {$POINTERMATH ON}
  pi := pi + 1;
  {$POINTERMATH OFF}
  ListBox1.Items.Add(Format('PInteger increment = %d', [NativeUInt(pi)]));
end;

指標的應用

指標可以用來處理資料緩衝區。例如,假設您有一個圖形紋理,組織成一個畫素陣列。每個畫素儲存為一個 cardinal 值。最高位元組包含透明度(Alpha)元件,下一個位元組包含 Blue 元件,下一個位元組包含 Green 元件,低位元組包含 Red 元件。

function TfrmPointers.PrepareData: TArray<Cardinal>;
var
  i: Integer;
begin
  SetLength(Result, 100000000);
  for i := Low(Result) to High(Result) do
    Result[i] := $0055AACC;
end;

指標的優點

指標提供了一種直接存取和操作記憶體的方式,可以提高程式的效率和靈活性。然而,指標也需要小心使用,以避免記憶體洩漏和其他問題。

最佳化影像處理程式碼

在影像處理中,最佳化程式碼以提高效能是一個重要的步驟。以下是如何最佳化一段Delphi程式碼,該程式碼負責交換影像中紅色和藍色色素的值。

原始程式碼

原始程式碼使用位元操作來交換紅色和藍色色素的值。這段程式碼如下:

var
  rgbData: TArray<Cardinal>;
  i: Integer;
  r, b: Byte;

begin
  rgbData := PrepareData;

  for i := Low(rgbData) to High(rgbData) do
  begin
    b := rgbData[i] AND $00FF0000 SHR 16;
    r := rgbData[i] AND $000000FF;

    rgbData[i] := rgbData[i] AND $FF00FF00
      OR (r SHL 16) OR b;
  end;
end;

最佳化程式碼

為了最佳化這段程式碼,我們可以使用指標來直接存取記憶體中的色素值。Delphi中,PByte是指向Byte的指標型別。透過使用兩個指標,pRedpBlue,分別指向紅色和藍色色素的位置,我們可以簡化程式碼並提高效能。

procedure TfrmPointers.btnPointerClick(Sender: TObject);
var
  rgbData: TArray<Cardinal>;
  i: Integer;
  r: Byte;
  pRed: PByte;
  pBlue: PByte;

begin
  rgbData := PrepareData;
  pRed := @rgbData[0];
  pBlue := pRed;
  Inc(pBlue, 2);

  for i := Low(rgbData) to High(rgbData) do
  begin
    r := pRed^;
    pRed^ := pBlue^;
    pBlue^ := r;
    Inc(pRed, SizeOf(rgbData[0]));
    Inc(pBlue, SizeOf(rgbData[0]));
  end;
end;

使用組合語言

在某些情況下,為了獲得最佳的效能,可能需要使用組合語言(Assembly Language)重寫程式碼。組合語言可以直接控制硬體資源,對於需要進行大量資料處理的任務尤其有用。

然而,使用組合語言也增加了程式碼的複雜度和維護成本。除非絕對必要,否則應盡量避免使用組合語言。

SSE2指令集

對於大型資料緩衝區的處理,使用SSE2(Streaming SIMD Extensions 2)指令集可以顯著提高效能。SSE2提供了SIMD(單指令多資料)操作,可以在單個指令中對多個資料元素進行相同的操作。

混合語言程式設計:結合Pascal和Assembly

在某些情況下,使用Assembly語言可以提高程式的效率和效能。下面是一個使用SSE2指令實作向量乘法的例子。

使用SSE2指令實作向量乘法

function Multiply_ASM(const A, B: TVec4): TVec4;
asm
  movups xmm0, [A]
  movups xmm1, [B]
  mulps xmm0, xmm1
  movups [Result], xmm0
end;

這個例子使用movups指令將向量A和B載入xmm0和xmm1暫存器,然後使用mulps指令將兩個向量的對應元素相乘,最後使用movups指令將結果儲存在Result中。

比較Pascal和Assembly實作

使用Pascal實作向量乘法的時間為53毫秒,而使用Assembly實作的時間為24毫秒。這表明使用Assembly語言可以提高程式的效率和效能。

混合語言程式設計的限制

在Win64編譯器中,方法只能使用純Pascal或純Assembly語言編寫,不允許混合使用。在Win32編譯器中,則允許混合使用Pascal和Assembly語言。

建議

在實作程式的一部分使用Assembly語言時,應該同時建立一個Pascal版本的實作,以便於除錯和作為參考實作。此外,應該使用預定義的條件編譯符號來決定是否編譯Pascal或Assembly語言。

使用預定義的條件編譯符號

可以使用以下預定義的條件編譯符號來決定是否編譯Pascal或Assembly語言:

  • ASSEMBLER:定義如果asm語法在當前平臺上受支援
  • CPUX86:定義如果當前CPU是X86
  • CPUX64:定義如果當前CPU是X64
  • CPU32BITS:定義如果當前CPU是32位
  • CPU64BITS:定義如果當前CPU是64位
  • CPUARM:定義如果當前CPU是ARM
  • CPUARM32:定義如果當前CPU是ARM32
  • CPUARM64:定義如果當前CPU是ARM64

從底層實作到高階應用的全面檢視顯示,Delphi 的記憶體管理機制,包含物件、介面與動態陣列的建立和銷毀,核心概念圍繞參照計數,藉此達到自動記憶體釋放與程式穩定性。然而,動態陣列和開放陣列引數的行為差異,特別是 const 修飾詞的效果,突顯了深入理解底層機制的重要性,才能避免非預期結果。透過 Slice 函式解密,更進一步展現 Delphi 編譯器的最佳化技巧。效能最佳化策略,包含引數傳遞方法、方法內聯以及指標運用,都對程式碼執行效率產生顯著影響。尤其指標的靈活操作,能直接控制記憶體,大幅提升影像處理等效能敏感操作的效率。然而,指標的運用伴隨著記憶體管理的風險,需要謹慎處理。展望未來,混合Pascal 與 Assembly 語言程式設計,特別是運用SSE2等指令集進行向量化運算,將持續成為Delphi 高效能運算的關鍵技術。對於追求極致效能的開發者,深入理解這些底層技術與最佳化策略至關重要。玄貓認為,掌握這些技巧,並搭配良好的程式碼設計,才能充分發揮 Delphi 的效能優勢。