C++ API 设计:清理公共接口

C++ API design: Clearing up public interface

本文关键字:接口 API 设计 C++      更新时间:2023-10-16

对于我的库,我想公开一个干净的公共 API,它不会分散实现细节的注意力。但是,正如您所看到的那样,这些细节甚至会泄漏到公共领域:某些类具有有效的公共方法,这些方法由库的其余部分使用,但对 API 的用户不是很有用,因此不需要成为它的一部分。公共代码的简化示例:

class Cookie;
class CookieJar {
public:
    Cookie getCookie();
}
class CookieMonster {
public:
    void feed(CookieJar cookieJar) {
        while (isHungry()) {
            cookieJar.getCookie();
        }
    }
    bool isHungry();
}

CookieJargetCookie()方法对库的用户没有用,他们可能不喜欢cookie。然而,当给它一个时,CookieMonster用它来养活自己。

有一些成语可以帮助解决这个问题。Pimpl 习惯用法提供了隐藏类的私有成员的功能,但对伪装不应该成为 API 一部分的公共方法几乎没有作用。也可以将它们移动到实现类中,但随后需要提供对它的直接访问,供库的其余部分使用。这样的标头如下所示:

class Cookie;
class CookieJarImpl;
class CookieJar {
public:
    CookieJarImpl* getImplementation() {
        return pimpl.get();
    }
private:
    std::unique_ptr<CookieJarImpl> pimpl;
}

如果您确实需要阻止用户访问这些方法,这很方便,但如果它只是一个烦恼,这并没有多大帮助。事实上,新的方法现在比上一个更无用,因为用户无法访问CookieJarImpl的实现。

另一种方法是将接口定义为抽象基类。这样可以显式控制公共 API 的一部分。任何私人详细信息都可以包含在此接口的实现中,用户无法访问该接口。需要注意的是,由此产生的虚拟调用对性能的影响甚至比 Pimpl 习语还要大。对于应该是一个高性能库来说,一个更干净的 API 的交易速度并不是很有吸引力。

为了详尽无遗,另一种选择是将有问题的方法设为私有,并在需要从外部访问它们的地方使用友元类。但是,这也使目标对象可以访问真正的私有成员,这在某种程度上破坏了封装。

到目前为止,对我来说最好的解决方案似乎是 Python 方式:与其试图隐藏实现细节,不如适当地命名它们,以便它们很容易识别为不是公共 API 的一部分,并且不会分散常规使用的注意力。想到的命名约定是使用下划线前缀,但显然这些名称是为编译器保留的,不鼓励使用它们。

是否有任何其他 C++ 命名约定来区分不打算从库外部使用的成员?或者你会建议我使用上面的替代方案之一或我错过的其他东西吗?

回答我自己的问题:这个想法基于接口 - 实现关系,其中公共 API 被显式定义为接口,而实现细节驻留在一个单独的类中扩展它,用户无法访问,但库的其余部分可以访问。

在使用 CRTP 实现静态多态性的过程中,按照 πάντα ῥεῖ 的建议避免虚拟调用开销,我意识到这种设计实际上根本不需要多态性,只要只有一种类型可以实现接口。这使得任何类型的动态调度都毫无意义。在实践中,这意味着扁平化你从静态多态性中获得的所有丑陋的模板,并最终得到一些非常简单的东西。没有朋友,没有模板,(几乎(没有虚拟通话。让我们将其应用于上面的示例:

下面是标头,仅包含带有示例用法的公共 API:

class CookieJar {
public:
    static std::unique_ptr<CookieJar> Create(unsigned capacity);
    bool isEmpty();
    void fill();
    virtual ~CookieJar() = 0 {};
};
class CookieMonster {
public:
    void feed(CookieJar* cookieJar);
    bool isHungry();
};
void main() {
    std::unique_ptr<CookieJar> jar = CookieJar::Create(20);
    jar->fill();
    CookieMonster monster;
    monster.feed(jar.get());
}

这里唯一的变化是将CookieJar转换为抽象类,并使用工厂模式而不是构造函数。

实现:

struct Cookie {
    const bool isYummy = true;
};
class CookieJarImpl : public CookieJar {
public:
    CookieJarImpl(unsigned capacity) :
        capacity(capacity) {}
    bool isEmpty() {
        return count == 0;
    }
    void fill() {
        count = capacity;
    }
    Cookie getCookie() {
        if (!isEmpty()) {
            count--;
            return Cookie();
        } else {
            throw std::exception("Where did all the cookies go?");
        }
    }
private:
    const unsigned capacity;
    unsigned count = 0;
};
// CookieJar implementation - simple wrapper functions replacing dynamic dispatch
std::unique_ptr<CookieJar> CookieJar::Create(unsigned capacity) {
    return std::make_unique<CookieJarImpl>(capacity);
}
bool CookieJar::isEmpty() {
    return static_cast<CookieJarImpl*>(this)->isEmpty();
}
void CookieJar::fill() {
    static_cast<CookieJarImpl*>(this)->fill();
}
// CookieMonster implementation
void CookieMonster::feed(CookieJar* cookieJar) {
    while (isHungry()) {
        static_cast<CookieJarImpl*>(cookieJar)->getCookie();
    }
}
bool CookieMonster::isHungry() {
    return true;
}

总的来说,这似乎是一个可靠的解决方案。它强制使用工厂模式,如果您需要复制和移动,则需要以与上述类似的方式自己定义包装器。这对于我的用例来说是可以接受的,因为无论如何,我需要使用它的类都是重量级资源。

我注意到的另一件有趣的事情是,如果你真的很喜欢冒险,你可以用reinterpret_casts替换static_casts,只要接口的每个方法都是你定义的包装器,包括析构函数,你可以安全地将任意对象分配给你定义的接口。可用于制作不透明的包装纸和其他恶作剧。

请考虑以下代码:

struct Cookie {};
struct CookieJarData {
    int count;
    int cost;
    bool whatever;
    Cookie cookie;
};
struct CookieJarInternal {
    CookieJarInternal(CookieJarData *d): data{d} {}
    Cookie getCookie() { return data->cookie; }
private:
    CookieJarData *data;
};
struct CookieJar {
    CookieJar(CookieJarData *d): data{d} {}
    int count() { return data->count; }
private:
    CookieJarData *data;
};
template<typename... T>
struct CookieJarTemplate: CookieJarData, T... {
    CookieJarTemplate(): CookieJarData{}, T(this)... {}
};
using CookieJarImpl = CookieJarTemplate<CookieJar, CookieJarInternal>;
class CookieMonster {
public:
    void feed(CookieJarInternal &cookieJar) {
        while (isHungry()) {
            cookieJar.getCookie();
        }
    }
    bool isHungry() {
        return false;
    }
};
void userMethod(CookieJar &cookieJar) {}
int main() {
    CookieJarImpl impl;
    CookieMonster monster;
    monster.feed(impl);
    userMethod(impl);
}

基本思想是创建一个类,该类同时是数据并派生自一堆子类。
因此,该类其子类,您可以通过选择正确的类型随时使用它们。这样,组合类就有一个完整的接口,并且如果几个组件共享相同的数据,则构建,但您可以轻松地返回仍然没有虚拟方法的该类的简化视图。

我对此有两个想法。在第一个中,您将创建一个 CookieJarPrivate 类,以向库的其他部分公开私有CookieJar方法。 CookieJarPrivate将在头文件中定义,该文件不构成公共 API 的一部分。 CookieJar会宣布CookieJarPrivate是它的friend。从技术上讲,cookiejar.h没有必要包含cookiejarprivate.h,但这样做可以阻止您的客户试图滥用friend,通过定义自己的CookieJarPrivate来访问实现细节。

class Cookie;
class CookieJarPrivate {
public:
    Cookie getCookie();
private:
    CookieJarPrivate(CookieJar& jar) : m_jar(jar) {}
    CookieJar& m_jar;
};
class CookieJar {
    friend class CookieJarPrivate;
public:
    CookieJarPrivate getPrivate() { return *this; }
private:
    Cookie getCookie();
};
class CookieMonster {
public:
    void feed(CookieJar cookieJar) {
        while (isHungry()) {
            cookieJar.getPrivate().getCookie();
        }
    }
    bool isHungry();
};
Cookie CookieJarPrivate::getCookie() {
    return m_jar.getCookie();
}

编译器应该能够内联CookieJarPrivate构造函数和getPrivate()方法,因此性能应该等效于对私有getCookie()的直接调用。如果编译器选择不内联对m_jar.getCookie()的调用,则可能会支付一次额外函数调用的代价 CookieJarPrivate::getCookie() 。如果两种方法都在同一翻译单元中定义,它可以选择这样做,特别是如果它可以证明私有getCookie()没有在其他任何地方调用,但肯定不能保证。


第二个想法是类类型的虚拟参数,在CookieMonster上具有私有构造函数和friend关系,因此该方法只能由可以构造此虚拟类型的代码调用,即只能CookieMonster。这类似于普通friend但具有更精细的粒度。

template <class T> class Restrict {
    friend T;
private:
    Restrict() {}
};
class Cookie;
class CookieMonster;
class CookieJar {
public:
    Cookie getCookie(Restrict<CookieMonster>);
};
class CookieMonster {
public:
    void feed(CookieJar cookieJar) {
        while (isHungry()) {
            cookieJar.getCookie({});
        }
    }
    bool isHungry();
};

其变体是非模板假人,没有friend,在非公共标头中定义。对于公开哪些方法,它仍然是精细的,但它们会公开到您的整个库,而不仅仅是CookieMonster

class PrivateAPI;
class Cookie;
class CookieJar {
public:
    Cookie getCookie(PrivateAPI);
};
class CookieMonster {
public:
    void feed(CookieJar cookieJar);
    bool isHungry();
};
class PrivateAPI {};
void CookieMonster::feed(CookieJar cookieJar) {
    while (isHungry()) {
        cookieJar.getCookie({});
    }
}

您应该在 CookieJar 类中使用一个私有容器,该容器在调用构造函数时被 cookie 填满。在下面的代码中,我使用了 STL C++ 库的向量作为容器,因为它使用起来很方便,但您可以使用其他东西(数组、列表、映射等(,并将 cookie 的属性设为私有。您也可以隐藏怪物isHungry属性以获得更好的封装。

如果要对库的用户隐藏 getCookie() 方法,则应将此方法设为私有,并将CookieMonster类视为CookieJar的友元类,以便CookieMonster将能够使用 getCookie() 方法,而用户将无法使用。

    #include<vector>
    using namespace std;
    class Cookie
    {
      private:
       string type;
       string chocolateFlavor;
    }
    class CookieJar {
    friend class CookieMonster;
    public:
        CookieJar(){ 
           //loads a cookie jar with 10 cookies
           for (int i = 0; i = 10; i++) { 
              Cookie cookie; 
              cookieContainer.push_back(cookie);
           }
         }
    private:
        vector<Cookie> cookieContainer;
        Cookie getCookie(){
          //returns a cookie to feed and deletes one in the container
          Cookie toFeed = cookieContainer[0];
          cookieContainer[0] = *cookieContainer.back();
          cookieContainer.pop_back();
          return toFeed;
        }
    }
    class CookieMonster {
    public:
        void feed(CookieJar cookieJar) {
            while (isHungry()) {
                cookieJar.getCookie();
            }
        }
    private:
        bool isHungry();
    }

另一种可能的方法是使用一种双重调度,如以下示例所示:

struct Cookie {};
struct CookieJarBase {
    Cookie getCookie() { return Cookie{}; }
};
struct CookieMonster;
struct CookieJar;
struct CookieJar: private CookieJarBase {
    void accept(CookieMonster &);
};
struct CookieMonster {
    void feed(CookieJarBase &);
    bool isHungry();
};
void CookieJar::accept(CookieMonster &m) {
    CookieJarBase &base = *this;
    m.feed(base);
}
void CookieMonster::feed(CookieJarBase &cj) {
    while (isHungry()) {
        cj.getCookie();
    }
}
bool CookieMonster::isHungry() { return false; }
int main() {
    CookieMonster monster;
    CookieJar cj;
    cj.accept(monster);
    // the following line doesn't compile
    // for CookieJarBase is not accesible
    // monster.feed(cj);
}

这样,您就没有虚拟方法,并且类CookieMonster的用户无法访问getCookie
老实说,问题转移到 feed ,现在用户无法使用,旨在直接用作accept方法。

解决您的问题的是虚拟模板方法,这根本不可能。
否则,如果您不想公开不可用的方法(如上例所示(,则无法避免虚拟方法或友元声明。

无论如何,这至少有助于隐藏您不想提供的内部方法,例如getCookie

我也想知道如何正确公开我的代码的 API,我发现 PIMPL 习语是最好的解决方案。你已经提到过了,但我不同意这句话:

Pimpl 成语提供隐藏班级的私人成员,但是 几乎没有掩饰不应该的公共方法 API 的一部分。

让我们考虑我们有以下代码:

namespace Core {
class Cookie {
};
class CookieJar {
 public:
  CookieJar(unsigned _capacity): capacity(_capacity) {}
  bool isEmpty() {
    return count == 0;
  }
  void fill() {
    count = capacity;
  }
  Cookie getCookie() {
    if (!isEmpty()) {
      this->count--;
      return Cookie();
    }
    throw std::exception();
  }
 private:
  const unsigned capacity;
  unsigned count = 0;
};
class CookieMonster {
 public:
  void feedOne(CookieJar* cookieJar) {
    cookieJar->getCookie();
    return;
  }
};
} // namespace Core

现在我们想添加 API 层,但要求是隐藏内部实现的一些方法和类。这完全可以在不修改核心的情况下完成!只需添加以下代码:

namespace API {
class CookieJar {
 friend class CookieMonster;
 public:
  CookieJar(unsigned _capacity) {
    this->impl_ = std::make_unique<Core::CookieJar>(_capacity);
  }
  bool isEmpty() {
    return impl_->isEmpty();
  }
  void fill() {
    return impl_->fill();
  }
 protected:
  std::experimental::propagate_const<std::unique_ptr<Core::CookieJar>> impl_;
};
class CookieMonster {
 public:
  CookieMonster() {
    this->impl_ = std::make_unique<Core::CookieMonster>();
  }
  void feedOne(CookieJar* jar) {
    return impl_->feedOne(jar->impl_);
  }
 protected:
  std::experimental::propagate_const<std::unique_ptr<Core::CookieMonster>> impl_;
};
} // namespace API

使用示例:

int main() {
  {
    using namespace Core;
    CookieJar* jar = new CookieJar(10);
    jar->fill();
    jar->getCookie();
    CookieMonster monster;
    monster.feedOne(jar);
    new Cookie();
  }
  {
    using namespace API;
    CookieJar* jar = new CookieJar(10);
    jar->fill();
    //jar->getCookie(); // <- hidden from API
    CookieMonster monster;
    monster.feedOne(jar);
    //new Cookie(); // <- hidden from API
  }
  return 0;
}

如您所见,使用 PIMPL,我们可以隐藏一些类,一些公共方法。还可以创建多个 API 层,而无需修改基本代码。PIMPL也适用于抽象类。