《Effective C++》《设计与声明——18、让接口容易被正确使用,不易被误用》

1、term18:Make interfaces easy to use correctly and hard to use incorrectly

前言:

C++ 在接口之海漂浮。函数接口,类接口,模板接口……每个接口都是客户与你的代码进行交互的一种方法。假设你正在面对的是一些“讲道理”的人,这些客户尝试把工作做好,他们希望能够正确使用你的接口。在这种情况下,如果接口被误用,你至少负一部分的责任。理想情况下,如果使用一个接口没有做到客户希望做到的,代码应该不能通过编译;如果代码通过了编译,那么它就能做到客户想要的。

1.1 引入新的类型

想要开发出一个容易被正确使用不容易被误用的接口,首先需要考虑客户可能出现的所有类型的错误。举个例子,假设你正在为一个表示日期的类设计一个构造函数:

class Date {
public:
	Date(int month, int day, int year);
	...
};

乍一看,这个接口可能看上去去合理的,但是客户很容易犯下至少两个错误:

第一,他们可能搞错参数的传递顺序:

Date d(30, 3, 1995);

第二,他们可能传递一个无效的月份或者天数:

Date d(2, 30, 1995); 

(上一个例子看上去很蠢,但是不要忘了在键盘上,数字2和3是挨着的,将2错打成3这样的错误并不罕见。)
许多客户端错误可以因为通过引入新的类型获得预防,的确,类型系统(type system)是你阻止不合要求的代码编译通过的主要盟友。在这种情况下,我们可以引入简单的外覆类型来区分天,月和年,然后在Date构造函数中使用这些类型:

struct Day{
	explicit Day(int d): val(d) {}
	int val;
};
struct Month {
	explicit Month(int m): val(m) {}
	int val;
 };
struct Year {
	explicit Year(int y): val(y){}
	int val;
 };
class Date {
public:
	Date(const Month& m, const Day& d, const Year& y);
...
};
Date d(30, 3, 1995); // error! 错误类型
Date d(Day(30), Month(3), Year(1995)); // error!  错误类型
Date d(Month(3), Day(30), Year(1995)); //  OK,正确类型

将Day,Month和Year数据封装在羽翼丰满的类中比上面简单的使用struct要更好(见条款22),但是使用struct就足以证明,明智而谨慎地引入新类型可以很好的阻止接口被误用的问题。

一旦正确的类型准备好了,就可以合理的约束这些类型的值。如,一年只有12个月份应该能够通过Month类型反映出来。方法之一是使用一个枚举类型来表示月份,但是枚举不是我们喜欢的类型安全的类型。例如,枚举可以像int一样使用(见条款2)。一个更加安全的解决方案是预先将所有有效的月份都定义出来。

class Month {
public:
	static Month Jan() { return Month(1); } // 函数,返回有效月份
	static Month Feb() { return Month(2); } 
	...
	static Month Dec() { return Month(12); }
	... // 其它成员函数
private:
	explicit Month(int m); // 阻止生成新的月份
	... // 月份专属数据
};
Date d(Month::Mar(), Day(30), Year(1995));

如果使用函数代替对象来表示指定月份值会让你觉的奇怪的话,可能是因为你忘记了非本地static对象的初始化次序有可能出现问题(见条款4)。
完整可编译代码如下:

#include <iostream>  
using namespace std;

struct Day {  
    explicit Day(int d) : val(d) {}  
    int val;  
     // 重载 operator<< 以便与 std::ostream 一起工作  
    friend std::ostream& operator<<(std::ostream& os, const Day& d) {  
        os << d.val;  
        return os;  
    }  
};  
  
struct Month {  
    static Month Jan() { return Month(1); }  
    static Month Feb() { return Month(2); }  
    static Month Mar() { return Month(3); }  
    static Month Apr() { return Month(4); }  
    static Month May() { return Month(5); }  
    static Month Jun() { return Month(6); }  
    static Month Jul() { return Month(7); }  
    static Month Aug() { return Month(8); }  
    static Month Sep() { return Month(9); }  
    static Month Oct() { return Month(10); }  
    static Month Nov() { return Month(11); }  
    static Month Dec() { return Month(12); }
public:  
    explicit Month(int m) : val(m) {}  
    int val;  
     // 重载 operator<< 以便与 std::ostream 一起工作  
    friend std::ostream& operator<<(std::ostream& os, const Month& m) {  
        os << m.val;  
        return os;  
    }  
};  
  
struct Year {  
    explicit Year(int y) : val(y) {}  
    int val;  
     // 重载 operator<< 以便与 std::ostream 一起工作  
    friend std::ostream& operator<<(std::ostream& os, const Year& y) {  
        os << y.val;  
        return os;  
    }  
};  
  
class Date {  
public:  
    Date(const Month& m, const Day& d, const Year& y)  
        : month(m), day(d), year(y) {  
        // 可以在这里添加一些逻辑来验证日期的有效性  
    }  
     // 获取日期的各个部分  
    Month getMonth() const { return month; }  
    Day getDay() const { return day; }  
    Year getYear() const { return year; }  
  
private:  
    Month month;  
    Day day;  
    Year year;  
};  
  
int main() {  
    // 现在可以正确创建Date对象了  
    Date d(Month::Mar(), Day(30), Year(1995));  
    // 错误的创建方式仍然会导致编译错误  
    // Date d(30, 3, 1995); // error! 错误类型  
    // Date d(Day(30), Month(3), Year(1995)); // error! 错误类型,因为构造函数参数顺序不对  
  
    // 正确创建Date对象的另一种方式(使用静态成员函数)  
    Date d2(Month::Jan(), Day(1), Year(2023));  
  
    // 输出日期以验证  
    cout << "Date: " << d2.getYear() << "-" << d2.getMonth() << "-" << d2.getDay() << endl;  
  
    return 0;  
}

1.2 对类型的操作进行限定

另外一种防止类似错误的方法是对类型能够做什么进行限制。进行限制的一般方法是添加const。举个例子,条款3解释了对于用户自定义的类型,把operator*的返回类型加上const能够防止下面错误的发生:

if (a * b = c) ... //原意是要做一次比较动作

1.3 提供行为一致的接口

事实上,这只是“使类型容易正确使用不容易被误用”的表现形式:除非有更好的理由,让你的自定义类型同内置类型的行为表现一致。客户已经知道像int一样的内置类型的行为是什么样子的,所以在任何合理的时候你应该努力使你的类型表现与其一致。举个例子,如果a和b是int类型,那么赋值给a*b是不合法的,所以除非有一个好的理由偏离这种行为,你应该使你的类型同样不合法。每当你不确定自定义类型的行为时,按照int来做就可以了。
避免自定义类型同内置类型无端不兼容的真正原因是:提供行为一致的接口。很少有其它特征比“一致性”更能使接口容易被使用了,也没有特征比“不一致性”更加导致接口容易被误用了。STL容器的接口基本上(虽然不是完全一致)是一致的,这使得它们使用起来相当容易。举个例子,每个STL容器有一个size成员函数,用来指出容器中的对象数量。与Java相比,arrays使用length属性(property)来表示对象数量,而String使用length方法(method)来表示,List使用size方法来表示;对于.NET来说,Array有一个Length属性,而ArrayList有一个Count属性。一些开发人员认为集成开发环境(IDE)使这种不一致性不再重要,但他们错了。不一致性会将精神摩擦强加到开发人员的工作中,没有任何IDE能够将其擦除。

1.4 使用智能指针消除客户管理资源的责任

1.4.1 让函数返回一个智能指针

一个要让客户记住做某事的接口比较容易被用错,因为客户有可能会忘记做。举个例子,条款13中引入一个工厂函数,在一个Investment继承体系中返回指向动态分配内存的指针:

Investment* createInvestment(); 

为了防止资源泄漏,createInvesment返回的指针最后必须被delete,但是这为至少两类客户错误的出现创造了机会:delete指针失败,多次delete同一个指针。

条款13展示了客户如何将createInvestment的返回值存入像auto_ptr或者tr1::shared_ptr一样的智能指针中,这样就将delete的责任交给智能指针。但是如果客户忘记使用智能指针该怎么办?在许多情况下,更好的接口是要先发制人,让工厂函数首先返回一个智能指针:

std::tr1::shared_ptr<Investment> createInvestment();

这就强制客户将返回值保存在tr1::shared_ptr中,从而完全消除了忘记delete不再被使用的底层Investment对象的可能性。

1.4.2 返回绑定删除器的智能指针

事实上,对于一个接口设计者来说,返回tr1::shared_ptr能够避免许多其他的有关资源释放的客户错误,因为条款14中解释道,在创建智能指针时,tr1::shared_ptr允许将一个资源释放函数——释放器(deleter)——绑定到智能指针上。
  假设客户从createInvestment得到一个Investment*指针,我们通过将这个指针传递给一个叫做getRidOfInvestment的函数来释放资源,而不是直接使用delete。这样的接口开启了另外一类客户错误的大门:客户可能会使用错误的资源析构机制(用delete而不是用提供的getRidOfInvestment接口)。createInvestment的实现者可以先发制人,返回一个tr1::shared_ptr,并将getRidOfInvestment绑定为删除器。
  tr1::shared_ptr提供了一个有两个参数的构造函数:需要被管理的指针和当引用计数为0时需要被调用的删除器。这就提供了一个创建用getRidOfInvestment作为删除器的空tr1::shared_ptr的方法,如:

std::tr1::shared_ptr<Investment> // 视图创建一个 null shared_ptr
pInv(0, getRidOfInvestment); // 并携带一个自定的删除器
							// 此式无法通过编译

上面不是有效的c++,tr1::shared_ptr构造函数的第一个参数必须为指针,但是0不是指针,是个int。虽然它可以转换成指针,但是在此例子中并不够好,因为tr1::shared_ptr坚持使用真实的指针。转型(cast)就能解决问题:

std::tr1::shared_ptr<Investment> // 创建一个 null shared_ptr
pInv( static_cast<Investment*>(0), // 并以getRidOfInvestment作为删除器
getRidOfInvestment); 

这意味着实现一个createInvestment的代码如下(返回值为绑定了getRidOfInvestment作为删除器的tr1::shared_ptr):

std::tr1::shared_ptr<Investment> createInvestment() {
	std::tr1::shared_ptr<Investment> retVal(static_cast<Investment*>(0),
											getRidOfInvestment);
	retVal = ...; // 令retVal指向正确对象
return retVal;
}

当然,如果在创建一个retVal之前就能够决定一个原始指针是不是由reVal来管理,将原始指针直接传递给retVal的构造函数比先将retVal初始化为null然后做一个赋值操作要好。为什么请看 条款26。

1.5 使用智能指针消除交叉-DLL错误

tr1::shared_ptr的一个特别好的性质是它会自动使用它的“每个指针专属的删除器”,因而消除另外一个客户错误——交叉(cross)-DLL错误。当一个对象在一个DLL中使用new被创建,但是在另外一个DLL中被delete时这个问题就会出现。在许多平台中,这样的交叉-DLL new/delete对会导致运行时错误。使用tr1::shared_ptr可以避免这种错误,因为它使用的默认的删除器来自创建tr1::shared_ptr的DLL。这就意味着,例如,如果Stock是一个继承自Investment的类,createInvestment实现如下:

std::tr1::shared_ptr<Investment> createInvestment() {
	return std::tr1::shared_ptr<Investment>(new Stock);
}

补充:
tr1::shared_ptr 是 C++ TR1(Technical Report 1)中的一个智能指针实现,它是 std::shared_ptr 的前身。std::shared_ptr 是 C++11 标准库中的一个重要组件,它实现了共享所有权的智能指针概念。当多个 shared_ptr 实例共享同一个对象时,对象的生命周期将一直持续到最后一个引用它的 shared_ptr 被销毁或重置。目前主要用std::shared_ptr ,这已经够用了。

2、面试相关

接口调用在项目中用的比较多,更偏于实用性,下面是面试中可能会问到的问题。

2.1、说一下异常处理在接口调用中的重要性。

异常处理在接口调用中的重要性不容忽视。它有助于提升代码的稳定性、可读性和可维护性,特别是在处理复杂的接口调用逻辑时。以下是异常处理在接口调用中的重要性体现:
(1)错误处理与程序稳定性:
接口调用过程中,由于网络问题、参数错误、资源限制等多种原因,可能会出现各种异常情况。如果没有适当的异常处理机制,这些异常可能导致程序崩溃或产生不可预测的行为。通过异常处理,我们可以捕获这些异常,并采取适当的措施,如记录日志、回滚事务或提供友好的错误提示,从而确保程序的稳定性和可靠性。
(2)提高代码可读性:
通过合理地使用异常处理,我们可以将错误处理逻辑与正常的业务逻辑分离,使代码结构更加清晰。这有助于其他开发人员更好地理解代码的功能和逻辑,降低维护成本。
(3)便于调试与定位问题:
当接口调用出现问题时,异常处理可以帮助我们快速定位问题所在。通过捕获异常并输出详细的错误信息,我们可以迅速了解问题的原因和发生位置,从而加快问题的解决速度。
(4)业务逻辑的完整性:
在接口调用中,有时候我们可能希望在某些异常情况发生时继续执行后续的逻辑,或者根据不同的异常类型执行不同的操作。通过异常处理,我们可以根据捕获到的异常类型进行条件判断,从而实现更灵活的业务逻辑处理。
(5)用户体验的提升:
对于面向用户的系统来说,友好的错误提示对于提升用户体验至关重要。通过异常处理,我们可以捕获接口调用中的错误,并向用户展示易于理解的错误提示,避免用户因为遇到不明原因的错误而感到困惑或不满。

2.2、在接口调用中,多态性如何帮助你实现灵活的代码复用和扩展?

在接口调用中,多态性是一个核心概念,它允许我们使用统一的接口来处理不同类型的对象,从而实现灵活的代码复用和扩展。以下是多态性在接口调用中如何帮助你实现这些目标的几个关键点:
(1)统一的接口调用:
多态性允许我们定义一个通用的接口,该接口可以被不同的类实现。这意味着,在编写调用接口的代码时,我们不需要关心具体的实现类是什么,只需要按照接口定义的方法进行操作即可。这种统一的接口调用方式大大简化了代码,并提高了代码的可读性和可维护性。
(2)代码复用:
通过多态性,我们可以编写一段通用的代码来处理实现了相同接口的多个对象。这些对象在内部可以有不同的实现细节,但对外都呈现出相同的接口。这使得我们可以在不修改已有代码的情况下,将新的实现类添加到系统中,并通过相同的接口进行调用。这种代码复用不仅减少了冗余代码,还提高了系统的可维护性和可扩展性。
(3)易于扩展:
多态性使得系统更加易于扩展。当需要添加新的功能或支持新的数据类型时,我们只需要创建新的类并实现相同的接口,然后将其注册到系统中即可。已有的调用代码不需要做任何修改,就可以与新的实现类协同工作。这种扩展方式既简单又高效,降低了系统的复杂性,并提高了开发的效率。
(4)开放封闭原则的支持:
多态性是实现开放封闭原则的重要手段之一。开放封闭原则强调软件实体(类、模块、函数等)应该是可扩展的,但是不可修改的。通过多态性,我们可以在不修改已有代码的情况下,通过添加新的实现类来扩展系统的功能。这符合开放封闭原则的精神,有助于提高系统的稳定性和可维护性。
(5)解耦与降低耦合度:
多态性有助于实现模块之间的解耦,降低系统各部件之间的耦合度。通过将接口和实现分离,我们可以使得各个模块更加独立,减少相互之间的依赖关系。这样,当某个模块发生变化时,其他模块受到的影响将最小化,提高了系统的可维护性和可扩展性。

3、总结

天堂有路你不走,地狱无门你自来。

4、参考

4.1《 Effective C++》
4.2 Effective C++条款18:让接口容易被正确使用,不容易被误用

  • 25
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值