位元詩人 使用 Dart FFI 封裝 C 語言 Opaque Pointer

Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

說明

不透明指標(Opaque Pointer) 是 C 語言中常見的設計模式,核心目的是隱藏 struct 的內部欄位實作,防止外部程式直接存取或修改。這種模式能達成良好的封裝性,被廣泛應用於各類 C 語言函式庫(如 SQLite、OpenSSL)。

由於採用 Opaque Pointer 的 C 程式架構天生具備物件導向(Object-Oriented)的特性,因此在 Dart FFI 中,我們建議使用 Class 進行封裝,這不僅符合 Dart 的語法習慣,開發體驗也最為自然。本文將展示一個完整的封裝實例。

C Opaque Pointer 範例程式

以下是該範例函式庫的標頭檔(Header File):

#ifndef POINT_H
#define POINT_H

#ifdef __cplusplus
extern "C" {
#endif

typedef struct point_t point_t;

point_t * point_new(double x, double y);
void point_delete(point_t *p);
int point_x(const point_t *p, double *out);
int point_y(const point_t *p, double *out);
int point_distance(const point_t *p1, const point_t *p2, double *dist);

#ifdef __cplusplus
}
#endif

#endif

這裡的設計關鍵在於:struct point_t 的具體欄位並未在標頭檔揭露,外部呼叫者無法得知其內部構造,必須透過函式庫提供的 API 進行存取。

具體實作如下:

#include <stdlib.h>
#include <math.h>
#include "point.h"

struct point_t {
    double x;
    double y;
};

point_t * point_new(double x, double y)
{
    point_t *p = malloc(sizeof(point_t)); 
    if (!p)
        return p;

    p->x = x;
    p->y = y;

    return p;
}

void point_delete(point_t *p)
{
    if (!p)
        return;

    free(p);
}

int point_x(const point_t *p, double *out)
{
    if (!p || !out)
        return -1;

    *out = p->x;
    return 0;
}

int point_y(const point_t *p, double *out)
{
    if (!p || !out)
        return -1;

    *out = p->y;
    return 0;
}

int point_distance(const point_t *p1, const point_t *p2, double *dist)
{
    if (!p1 || !p2 || !dist) 
        return -1;

    double dx = p1->x - p2->x;
    double dy = p1->y - p2->y;
    *dist = sqrt(dx * dx + dy * dy);

    return 0;
}

由於 struct point_t 的定義僅存在於 C 原始碼中,成功達成了封裝效果。讀者可自行閱讀程式碼,邏輯相當簡明直觀。

使用以下指令編譯函式庫:

$ gcc -c -fPIC point.c point.o
$ gcc -shared -o libpoint.so point.o -lm

注意: 由於實作中引用了 math 函式庫,編譯時需加上 -lm 參數。

在 Dart 中封裝 C Opaque Pointer

接著,我們在 Dart 中利用 FFI 技術來包裝這個函式庫:

import 'dart:io';
import 'dart:ffi' as ffi;
import 'package:ffi/ffi.dart';

final class _PointHandler extends ffi.Opaque {}

typedef _cPointNew = ffi.Pointer<_PointHandler> Function(
    ffi.Double, ffi.Double);
typedef _dartPointNew = ffi.Pointer<_PointHandler> Function(double, double);

typedef _cPointDelete = ffi.Void Function(ffi.Pointer<_PointHandler>);
typedef _dartPointDelete = void Function(ffi.Pointer<_PointHandler>);

typedef _cPointCoord = ffi.Int Function(
    ffi.Pointer<_PointHandler>, ffi.Pointer<ffi.Double>);
typedef _dartPointCoord = int Function(
    ffi.Pointer<_PointHandler>, ffi.Pointer<ffi.Double>);

typedef _cPointDistance = ffi.Int Function(ffi.Pointer<_PointHandler>,
    ffi.Pointer<_PointHandler>, ffi.Pointer<ffi.Double>);
typedef _dartPointDistance = int Function(ffi.Pointer<_PointHandler>,
    ffi.Pointer<_PointHandler>, ffi.Pointer<ffi.Double>);

class Point implements ffi.Finalizable {
  static final String _libPath =
      Uri.file(Platform.script.path).resolve('libpoint.so').toFilePath();
  static final ffi.DynamicLibrary _dylib = ffi.DynamicLibrary.open(_libPath);

  static final _dartPointNew _point_new =
      _dylib.lookup<ffi.NativeFunction<_cPointNew>>('point_new').asFunction();

  static final _dartPointDelete _point_delete = _dylib
      .lookup<ffi.NativeFunction<_cPointDelete>>('point_delete')
      .asFunction();

  static final ffi.NativeFinalizer _finalizer = ffi.NativeFinalizer(
    _dylib
        .lookup<
            ffi.NativeFunction<
                ffi.Void Function(ffi.Pointer<_PointHandler>)>>('point_delete')
        .cast(),
  );

  static final _dartPointCoord _point_x =
      _dylib.lookup<ffi.NativeFunction<_cPointCoord>>('point_x').asFunction();

  static final _dartPointCoord _point_y =
      _dylib.lookup<ffi.NativeFunction<_cPointCoord>>('point_y').asFunction();

  static final _dartPointDistance _point_distance = _dylib
      .lookup<ffi.NativeFunction<_dartPointDistance>>('point_distance')
      .asFunction();

  late final ffi.Pointer<_PointHandler> _cPoint;
  bool _isDisposed = false;

  Point(double x, double y) {
    final ptr = _point_new(x, y);
    if (ptr == ffi.nullptr) throw Exception('Point(\(x,\)y) is not created');
    _cPoint = ptr;

    _finalizer.attach(this, _cPoint.cast(), detach: this);
  }

  dispose() {
    if (_isDisposed) return;
    _finalizer.detach(this);
    _point_delete(_cPoint);
    _isDisposed = true;
  }

  double get x {
    if (_isDisposed) throw StateError("Point is disposed");

    final out = calloc<ffi.Double>();
    try {
      final result = _point_x(_cPoint, out);
      if (result != 0) throw Exception("Failed to get X");
      return out.value;
    } finally {
      calloc.free(out);
    }
  }

  double get y {
    if (_isDisposed) throw StateError("Point is disposed");

    final out = calloc<ffi.Double>();
    try {
      final result = _point_y(_cPoint, out);
      if (result != 0) throw Exception("Failed to get Y");
      return out.value;
    } finally {
      calloc.free(out);
    }
  }

  static double distance(Point p1, Point p2) {
    final out = calloc<ffi.Double>();
    try {
      final result = _point_distance(p1._cPoint, p2._cPoint, out);
      if (result != 0) throw Exception("Failed to calculate distance");
      return out.value;
    } finally {
      calloc.free(out);
    }
  }
}

void main() async {
  final p = Point(0, 0);
  final q = Point(3, 4);
  assert(q.x == 3);
  assert(q.y == 4);
  assert(Point.distance(p, q) == 5);
}

技術細節說明

  1. 語意對應:由於該 C 函式庫的設計邏輯與類別(Class)高度相似,我們選擇使用 Point 類別進行包裝,使呼叫端能以物件化的方式操作。
  2. 型別復用:由於 point_xpoint_y 的函式簽章(Function Prototype)完全相同,在定義 FFI typedef 時只需編寫一次即可。
  3. 記憶體管理與 Finalizable
    • 由於 Dart 具備垃圾回收(GC)機制,但 C 端的記憶體需手動釋放。
    • 我們實作了 Finalizable 接口並搭配 NativeFinalizer。當 Dart 的 Point 物件被 GC 回收時,系統會自動呼叫 C 的 point_delete 釋放記憶體。
    • 同時保留 dispose() 函式,供開發者在需要時主動進行記憶體清理。

Dart FFI 的記憶體釋放機制

在 Dart FFI 中,管理 Native 記憶體(如 C 的 malloc)需要特別小心,因為 Dart GC 只能追蹤 Dart 物件,無法感知 C 端配置的記憶體。本範例採用了 「手動釋放 + 自動保險」 的雙重機制:

  1. 為什麼需要 dispose()
    Dart GC 觸發的時間點具備不確定性。如果你的程式頻繁產生大量 Native 物件,單靠 GC 可能會導致系統記憶體來不及釋放。提供 dispose() 讓開發者能主動立即回收記憶體。
  2. 為什麼需要 NativeFinalizer
    這是為了防止開發者疏忽。當 Point 物件被 GC 回收時,NativeFinalizer 會自動觸發綁定的 C 函式(point_delete),作為防止記憶體洩漏的最後一道防線。
  3. detach 的關鍵作用
    dispose() 中呼叫 _finalizer.detach(this) 極其重要。它會告訴 Dart 引擎取消自動回收的約定。若遺漏此步驟,當 GC 啟動時會對同一塊位址再次執行 free,導致 Double Free 錯誤並引發程式崩潰。
  4. ffi.Finalizable 的保護
    讓類別實作 Finalizable 接口能防止 「過早回收(Premature Finalization)」。這能確保 Dart 在執行完與物件相關的最後一行邏輯前,不會因為最佳化而提前觸發 Finalizer。

進一步改進本程式

自動偵測作業系統與副檔名

為了讓 Dart 程式具備跨平台能力,建議根據宿主系統(Platform.isAndroid, Platform.isWindows 等)自動偵測動態函式庫的名稱與副檔名(例如 .so, .dll, .dylib)。

模組化封裝

為了示範簡便,本文將類別定義與 main 函式放在同一個檔案。在實際生產環境中,建議將類別定義抽離成獨立的 Dart 檔案,以利程式碼重用與維護。

結語

本文透過具體範例,展示了在 Dart FFI 中封裝 C 語言 Opaque Pointer 的標準流程。這種模式在整合現有 C/C++ 專案時極為常見,讀者可參考此架構應用於自己的跨平台開發專案中。

關於作者

位元詩人 (ByteBard) 是資訊領域碩士,專注於從原型到產品的開發過程,並以工具驅動的方式持續探索技術。喜歡以開源專案作為成果,回饋社群。

主要方向包括:自用工具的打磨 (dogfooding)、編譯器前端在工具開發中的應用,以及將研究與實驗轉化為可維護的開源成果。

除了技術之外,也喜歡日本料理和黑咖啡,偶爾自助旅行,將生活中的靈感融入技術隨筆。