为什么要使用需要强制转换为公共API而不是类型安全结构指针的不透明“句柄”?


27

我正在评估一个库,其公共API当前如下所示:

libengine.h

/* Handle, used for all APIs */
typedef size_t enh;


/* Create new engine instance; result returned in handle */
int en_open(int mode, enh *handle);

/* Start an engine */
int en_start(enh handle);

/* Add a new hook to the engine; hook handle returned in h2 */
int en_add_hook(enh handle, int hooknum, enh *h2);

请注意,这enh是一个通用句柄,用作几种不同数据类型(enginehooks)的句柄。

在内部,大多数这些API当然都将“句柄”转换为它们已经实现的内部结构malloc

引擎

struct engine
{
    // ... implementation details ...
};

int en_open(int mode, *enh handle)
{
    struct engine *en;

    en = malloc(sizeof(*en));
    if (!en)
        return -1;

    // ...initialization...

    *handle = (enh)en;
    return 0;
}

int en_start(enh handle)
{
    struct engine *en = (struct engine*)handle;

    return en->start(en);
}

就我个人而言,我讨厌将东西隐藏在typedefs 后面,尤其是当它危及类型安全性时。(鉴于enh,我怎么知道它实际上指的是什么?)

因此,我提交了一个拉取请求,建议进行以下API更改(在修改整个库以使其符合之后):

libengine.h

struct engine;           /* Forward declaration */
typedef size_t hook_h;    /* Still a handle, for other reasons */


/* Create new engine instance, result returned in en */
int en_open(int mode, struct engine **en);

/* Start an engine */
int en_start(struct engine *en);

/* Add a new hook to the engine; hook handle returned in hh */
int en_add_hook(struct engine *en, int hooknum, hook_h *hh);

当然,这使内部API实现看起来好很多,消除了强制转换,并从/从消费者的角度维护了类型安全。

libengine.c

struct engine
{
    // ... implementation details ...
};

int en_open(int mode, struct engine **en)
{
    struct engine *_e;

    _e = malloc(sizeof(*_e));
    if (!_e)
        return -1;

    // ...initialization...

    *en = _e;
    return 0;
}

int en_start(struct engine *en)
{
    return en->start(en);
}

我之所以喜欢这样做,原因如下:

但是,该项目的所有者在请求请求时拒绝了(解释为):

我个人不喜欢暴露的想法struct engine。我仍然认为目前的方式更清洁,更友好。

最初,我为钩子句柄使用了另一种数据类型,但是后来决定切换为use enh,因此所有类型的句柄都共享相同的数据类型以使其简单。如果这令人困惑,我们当然可以使用其他数据类型。

让我们看看其他人对此PR的看法。

该库目前处于私有Beta阶段,因此不需要担心任何消费者代码。另外,我对名称有些混淆。


不透明的句柄如何比命名的不透明结构更好?

注意:我在Code Review上关闭了该问题。


1
我已经将标题编辑为更相信表达您问题核心的内容。如果我误解了,请随时恢复。
Ixrec

1
@Ixrec更好,谢谢。在写下整个问题之后,我用尽了脑力想出一个好标题。
乔纳森·莱因哈特

Answers:


33

“简单就是更好”的口头禅已经变得过于教条。如果简单会使其他事情复杂化,则不一定总会更好。汇编很简单-每个命令都比高级语言命令简单得多-但是汇编程序比做相同事情的高级语言更复杂。您的情况是统一手柄类型enh使类型更简单,但代价是使功能复杂。由于通常项目的类型与其功能相比通常会以亚线性速率增长,因此随着项目的扩大,如果可以简化功能,则通常会选择更复杂的类型-因此在这方面,您的方法似乎是正确的方法。

该项目的作者担心您的方法是“ 暴露struct engine ”。我会向他们解释,这不是揭结构本身-只是一个事实,即有一个命名结构engine。库的用户已经需要知道这种类型,例如,他们需要知道en_add_hook是该类型,而第一个参数是不同类型。因此,它实际上使API变得更加复杂,因为它需要在其他地方记录这些类型,而不是让函数的“签名”记录这些类型,并且因为编译器无法再为程序员验证类型。

需要注意的一件事-您的新API使用户代码更加复杂,因为它无需编写:

enh en;
en_open(ENGINE_MODE_1, &en);

现在,他们需要更复杂的语法来声明其句柄:

struct engine* en;
en_open(ENGINE_MODE_1, &en);

但是,解决方案非常简单:

struct _engine;
typedef struct _engine* engine

现在您可以直接编写:

engine en;
en_open(ENGINE_MODE_1, &en);

我忘了提到该库声称遵循Linux Coding Style,这恰好也是我遵循的。在这里,您会发现明显struct不鼓励使用类型定义结构来避免编写。
乔纳森·莱因哈特

@JonathonReinhart他正在对结构体而不是结构体本身进行类型定义。
棘轮怪胎

@JonathonReinhart并实际阅读该链接,我看到“完全不透明的对象”是允许的。(第5章规则a)
棘手怪胎

是的,但仅在极少数情况下。老实说,我相信这样做是为了避免重写所有mm代码来处理pte typedef。查看自旋锁代码。它完全是特定于Arch的(没有公共数据),但是它们从不使用typedef。
乔纳森·莱因哈特

8
我希望typedef struct engine engine;并使用engine*:引入了一个较少的名称,这很明显是一个类似的句柄FILE*
Deduplicator

16

双方似乎都感到困惑:

  • 使用手柄方法不需要为所有手柄使用单一手柄类型
  • 公开struct名称不会公开其详细信息(仅公开其存在)

在像C这样的语言中,使用句柄而不是裸指针是有优势的,因为交出指针可以直接操纵指针(包括对指针的调用)。 free),而移交句柄则要求客户端通过API来执行任何操作。

但是,只有通过 typedef类型不安全,可能会引起很多麻烦。

因此,我个人的建议是转向安全型手柄,我认为这将使你们俩都满意。这很简单地完成:

typedef struct {
    size_t id;
} enh;

typedef struct {
    size_t id;
} oth;

现在,一个人既不能不小心将其2当作手柄使用,也不能不小心将其交给扫帚棒,因为它不适合作为发动机的手柄。


因此,我提交了一个请求请求,建议进行以下API更改(在修改整个库以使其合规之后)

那是您的错误:在从事开放源代码库的重要工作之前,请联系作者/维护者以预先讨论更改。这将使你们俩在做什么(或不做什么)上达成一致,并避免不必要的工作和由此而造成的挫败感。


1
谢谢。但是,您没有涉及到如何处理手柄。我已经实现了一个实际的基于句柄的API,即使通过typedef,也从不公开指针。它涉及到数据的〜昂贵查找在每个API调用的入口-很像方式的Linux中查找struct fileint fd。对于用户模式库IMO来说,这肯定是多余的。
Jonathon Reinhart 2015年

@JonathonReinhart:好吧,由于该库已经提供了句柄,所以我不觉得需要扩展。实际上,存在多种方法,从简单地将指针转换为整数到具有“池”并使用ID作为键。您甚至可以在Debug(ID +查找,用于验证)和Release(仅转换指针,用于速度)之间切换方法。
Matthieu M.

整数表索引的重用实际上会遇到ABA问题,即3释放一个对象(索引),然后创建一个新对象,不幸的是3再次被分配了索引。简而言之,除非将引用计数(以及关于对象的共享所有权的约定)纳入API设计的明确部分,否则很难在C中拥有安全的对象生存期机制。
rwong

2
@rwong:这只是天真的计划中的一个问题;例如,您可以轻松地集成纪元计数器,以便在指定旧句柄时会出现纪元不匹配的情况。
Matthieu M.

1
@JonathonReinhart建议:您可以在问题中提及“严格的别名规则”,以帮助将讨论引向更重要的方面。
rwong

3

这是需要不透明手柄的情况;

struct SimpleEngine {
    int type;  // always SimpleEngine.type = 1
    int a;
};

struct ComplexEngine {
    int type;  // always ComplexEngine.type = 2
    int a, b, c;
};

int en_start(enh handle) {
    switch(*(int*)handle) {
    case 1:
        // treat handle as SimpleEngine
        return start_simple_engine(handle);
    case 2:
        // treat handle as ComplexEngine
        return start_complex_engine(handle);
    }
}

当库中有两个或多个结构类型具有相同的字段头部分(如上述“类型”)时,可以将这些结构类型视为具有共同的父结构(例如C ++中的基类)。

您可以将标头部分定义为“结构引擎”,如下所示;

struct engine {
    int type;
};

struct SimpleEngine {
    struct engine base;
    int a;
};

struct ComplexEngine {
    struct engine base;
    int a, b, c;
};

int en_start(struct engine *en) { ... }

但这是一个可选的决定,因为无论使用结构引擎如何,都需要类型转换。

结论

在某些情况下,有一些原因为什么使用不透明的句柄而不是不透明的命名结构。


我认为使用工会会使此操作更安全,而不是对可能会移动的字段进行危险的强制转换。查看我整理出的完整示例的要点
Jonathon Reinhart 2015年

但是实际上,switch首先使用“虚拟功能”来避免这种情况可能是理想的,并且可以解决整个问题。
Jonathon Reinhart 2015年

您在gist上的设计比我建议的要复杂。当然,它使转换变得更少,类型安全且更智能,但是引入了更多的代码和类型。我认为,获得类型安全性似乎变得太棘手。我,也许是图书馆作者决定遵循KISS而不是类型安全。
高桥昭夫(Akio Takahashi),

好吧,如果您想保持它的简单性,也可以完全省略错误检查!
乔纳森·莱因哈特

在我看来,简化设计比进行一些错误检查更为可取。在这种情况下,此类错误检查仅存在于API函数中。此外,您可以使用union删除类型转换,但请记住,工会自然是类型不安全的。
高桥昭夫(Akio Takahashi)

2

handles方法最明显的好处是您可以修改内部结构而不会破坏外部API。当然,您仍然必须修改客户端软件,但是至少您没有更改界面。

它所做的另一件事是提供了在运行时从许多可能的类型中进行选择的能力,而不必为每个类型提供显式的API接口。某些应用程序(例如,来自几种不同传感器类型的传感器读数,其中每个传感器略有不同,并生成略有不同的数据)都很好地响应了这种方法。

由于无论如何都将结构提供给客户端,因此您会牺牲一点点类型安全性(仍然可以在运行时检查该类型安全性),以获取更为简单的API,尽管该API需要强制转换。


5
“您可以在不使用..的情况下修改内部结构”-您也可以使用前向声明方法。
user253751

“前向声明”方法是否仍然需要您声明类型签名?如果更改结构,这些类型签名是否仍不会更改?
罗伯特·哈维

前向声明只要求您声明类型的名称-它的结构保持隐藏。
伊丹·阿里

那么,即使不强制执行类型结构,前向声明又有什么好处呢?
罗伯特·哈维

6
@RobertHarvey记住-我们正在谈论的是C。没有方法,因此除了名称和结构之外,类型别无其他。如果确实执行了该结构,则该结构将与常规声明相同。在不强制使用结构的情况下公开名称的意义在于,可以在函数签名中使用该类型。当然,如果没有结构,则只能使用指向类型的指针,因为编译器无法知道其大小,但是由于C中没有使用指针的隐式指针强制转换,足以用于静态类型来保护您。
伊丹·阿里

2

德贾武

不透明的句柄如何比命名的不透明结构更好?

我遇到了完全相同的场景,只是有一些细微的差异。在我们的SDK中,我们做了很多类似的事情:

typedef void* SomeHandle;

我的唯一建议是使其与我们的内部类型匹配:

typedef struct SomeVertex* SomeHandle;

对于使用SDK的第三方而言,它没有任何区别。这是不透明的类型。谁在乎?它对ABI *或源兼容性没有影响,并且使用SDK的新版本仍然需要重新编译插件。

*请注意,正如gnasher指出的那样,实际上在某些情况下,指向struct和void *的指针的大小实际上可能是不同的大小,在这种情况下会影响ABI。像他一样,我从未在实践中遇到过。但是从这个角度来看,第二种方法实际上可以在某些模糊的环境中提高可移植性,因此这是赞成第二种方法的另一个原因,尽管对于大多数人来说可能是没有根据的。

第三方错误

此外,对于内部开发/调试,我比类型安全有更多的原因。我们已经有很多插件开发人员在他们的代码中存在错误,因为两个类似的句柄(PanelPanelNew,即两个)都使用void*typedef作为它们的句柄,并且由于仅使用了一个错误地将错误的句柄传递给了错误的位置void*为了一切。因此,这实际上导致了那些使用SDK。他们的错误也花费了内部开发团队大量的时间,因为他们会发送错误报告来抱怨我们的SDK中的错误,因此我们必须调试插件,并发现它实际上是由插件中的错误传递了错误的句柄引起的放置到错误的位置(当每个句柄都是的别名时,即使没有警告也很容易允许这样做void*size_t)。因此,我们不必要地浪费时间为第三方提供调试服务,这是由于我们出于隐瞒所有内部信息(甚至是内部内部名称)的概念纯洁性而导致的错误所致structs

保持Typedef

所不同的是,我提议我们typedef仍然坚持不动,而不是让客户写struct SomeVertex影响将来插件版本的源兼容性。虽然我个人喜欢不使用structC进行类型定义的想法,但从SDK的角度来看,这typedef可以有所帮助,因为整个过程都是不透明的。因此,我建议您仅针对公开使用的API放宽此标准。对于使用SDK的客户端,句柄是否是指向结构,整数等的指针无关紧要。唯一重要的是,两个不同的句柄不会别名相同的数据类型,因此它们不会错误地将错误的句柄传递到错误的位置。

类型信息

内部开发人员最需要避免铸造的地方。从SDK 中隐藏所有内部名称的这种美感是某种概念上的美感,其代价是丢失所有类型的信息,并且需要我们不必要地将演员表撒在调试器中以获取关键信息。尽管C程序员应该在C语言中已经习惯了这一点,但不必要地要求它只是自找麻烦。

概念理想

通常,您要提防那些类型的开发人员,他们将纯粹的概念性概念置于远远超出所有实际日常需求的位置。这些将使您的代码库的可维护性在寻求某种乌托邦理想的基础上扎根,使整个团队避免在沙漠中使用防晒霜,因为担心防晒霜是不自然的,并且可能导致维生素D缺乏,而一半的工作人员正因皮肤癌而死。

用户端首选项

即使从使用该API的用户的严格角度来看,他们还是希望使用越野车API或运行良好的API,但会公开一些他们几乎不希望交换的名称?因为那是实际的权衡。在通用上下文之外不必要地丢失类型信息正在增加错误的风险,并且由于多年来在团队范围内设置大规模代码库,墨菲定律趋于相当适用。如果您过多地增加了错误的风险,则很有可能至少会得到更多的错误。在大型团队中花很长时间才发现,可以想象到的各种人为错误最终都会从潜能变成现实。

因此,也许这是向用户提出的问题。“您是希望使用Buggier SDK还是公开一些您根本不会在乎的内部不透明名称的SDK?” 而且,如果这个问题似乎提出了错误的二分法,那么我想说,需要更多的团队在大型环境中的经验,才能认识到更高的错误风险最终会在长期内体现出真正的错误。开发人员对避免错误的信心有多大无关紧要。在整个团队范围内,它有助于更​​多地考虑最薄弱的环节,至少可以考虑最简单和最快的方法来防止它们绊倒。

提案

因此,我建议您在此处进行折衷,这仍然使您能够保留所有调试优势:

typedef struct engine* enh;

...即使以牺牲typedef为代价struct,这真的会杀死我们吗?可能不是,所以我也建议您一些实用主义,但对那些更愿意通过使用size_t此处并无缘无故地从/强制转换为整数来使指数级调试变得更困难的开发人员,除了进一步隐藏已经为99的信息之外,更是如此。对用户隐藏的%并且可能不会造成更大的危害size_t


1
这是一个微小的区别:根据C标准,所有的“指针结构”具有相同的表示,所以做所有的“指针联盟”,这样做“无效*”和“字符*”,但一个void *和“指针结构”可以具有不同的sizeof()和/或不同的表示形式。在实践中,我从未见过。
gnasher729

@ gnasher729同样,也许我应该将该部分限定为在往来void*size_t往后铸造时的可移植性的潜在损失,这是避免过度铸造的另一个原因。考虑到我们所针对的平台(实际上始终是台式机平台:Linux,OSX,Windows),我在实践中从未见过它,所以我略去了它。


1

我怀疑真正的原因是惯性,这就是他们一直以来所做的并且有效,所以为什么要改变它?

我能看到的主要原因是,不透明的手柄使设计师可以将所有东西放在它后面,而不仅仅是结构。如果API返回并接受多种不透明类型,则它们对调用方的外观都是相同的,并且如果精细打印更改,就不会有任何编译问题或需要重新编译。如果en_NewFlidgetTwiddler(handle ** newTwiddler)更改为返回指向Twiddler的指针而不是句柄,则API不会更改,并且任何新代码都将在使用句柄之前默默地使用指针。同样,如果指针越过边界,也没有操作系统或其他任何东西悄悄“修复”指针的危险。

当然,这样做的缺点是,调用方可以将任何内容完全馈入其中。你有64位的东西吗?将其推入API调用的64位插槽中,看看会发生什么。

en_TwiddleFlidget(engine, twiddler, flidget)
en_TwiddleFlidget(engine, flidget, twiddler)

两者都可以编译,但我敢打赌,其中只有一个可以满足您的需求。


1

我相信这种态度源于长期的哲学,即捍卫C库API免受初学者的滥用。

特别是,

  • 库作者知道它是该结构的指针,并且库代码可以看到该结构的详细信息。
  • 所有使用该库的经验丰富的程序员也都知道这是一些不透明结构的指针。
    • 他们有足够的痛苦经历,以至于不弄乱存储在这些结构中的字节
  • 没有经验的程序员都不知道。
    • 他们将尝试处理memcpy不透明的数据或增加结构中的字节或单词。去黑客。

长期的传统对策是:

  • 掩盖一个事实,即不透明的句柄实际上是指向存在于同一进程内存空间中的不透明结构的指针。
    • 为此,可以声明它是一个整数位数,该整数位数与a void*
    • 出于特殊考虑,也要伪装指针的位,例如
      struct engine* peng = (struct engine*)((size_t)enh ^ enh_magic_number);

这只是说它具有悠久的传统;对于这是对还是错,我没有个人意见。


3
除了荒谬的xor之外,我的解决方案还提供了这种安全性。客户仍然不知道结构的大小或内容,还有类型安全的附加好处。我看不出如何滥用size_t来保存指针会更好。
乔纳森·莱因哈特

@JonathonReinhart客户端实际上不知道结构的可能性很小。问题是更多的:他们可以获取结构,还可以将修改后的版本返回到您的库中吗?不只是开源,而且更普遍。解决方案是现代内存分区,而不是愚蠢的XOR。
莫兹

你在说什么?我要说的是,您不能编译任何试图取消引用指向该结构的指针的代码,或者做任何需要了解其大小的事情。当然,如果您确实愿意,可以在整个进程的堆上使用memset(,0,)。
乔纳森·莱因哈特

6
这个论点听起来很像在捍卫马基雅维利。如果用户希望将垃圾传递给我的API,则无法阻止它们。引入这种类型不安全的接口几乎无济于事,因为它实际上使意外误用API变得容易。
ComicSansMS

@ComicSansMS感谢您提到“意外”,因为这就是我在此要真正防止的。
乔纳森·莱因哈特
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.