前言
friend、static、const这三个关键字经常被放在一起讲,是因为它们都在回答同一个问题:谁可以访问什么,以及什么时候可以修改。friend决定「非成员能不能碰私有成员」,static决定「这个成员属于对象还是属于类」,const决定「这个接口承诺不修改什么」。三者各自独立,却在类的设计里频繁交叉:一个const成员函数里能不能改缓存?一个static成员函数能不能访问普通成员?友元函数要不要加const引用参数?这些问题都要求把三者的语义分清楚。
常见的误解有:
- 以为
friend是双向的,或者会传递给派生类; - 以为
static成员函数里可以用this; - 以为在成员函数后面写
const只是「文档性质」,编译器不会检查; - 以为
const变量一定是编译期常量,因而把它用在需要常量表达式的地方。
这些理解偏差在编译期通常就会暴露,但错误信息往往很长,容易被误读成「编译器坏了」。本文按 C++17 逐条讲清三个关键字的语义,再用一个账户类把它们串起来。文中代码未在本机编译或运行,语义按标准推演,读者应以自己的工具链为准。
一、friend:有选择地开放私有访问
类的私有成员默认只对类的成员函数和友元开放。friend声明的作用,是把某个函数或某个类加入这个白名单。
1.1 友元函数
最典型的用途是重载流输出运算符。operator<<的左操作数是std::ostream,不可能是我们自己的类,所以它只能写成非成员函数;但它又需要读私有成员,于是声明为友元:
// 适用于 C++17
#include <ostream>
class Point {
public:
Point(int x, int y) : x_(x), y_(y) {}
friend std::ostream& operator<<(std::ostream& os, const Point& p);
private:
int x_;
int y_;
};
std::ostream& operator<<(std::ostream& os, const Point& p) {
os << '(' << p.x_ << ", " << p.y_ << ')';
return os;
}几个要点:
- 类内的
friend声明只是「授予访问权」,不是函数声明意义上的成员。真正的声明在命名空间作用域,定义时不能写friend。 - 若在类内直接给出函数体(友元定义),该函数只能通过实参依赖查找(argument-dependent lookup, ADL)找到,普通的名字查找看不到它。这在写模板类时容易踩坑。
- 友元函数不是成员函数,没有
this,因此不存在「友元函数能不能是 const」的问题;它接收的const Point&才决定了能否修改对象。 - 友元关系不互惠:A 声明 B 是友元,只表示 B 能访问 A 的私有成员,不代表 A 能访问 B 的。
- 友元关系不传递:B 是 A 的友元、C 是 B 的友元,不意味着 C 能访问 A。
- 友元关系不被继承:基类的友元不会自动成为派生类的友元,反之亦然。
1.2 友元类与友元成员函数
friend class Auditor;表示Auditor的所有成员函数都能访问本类的私有成员。范围要慎重:这等于把整个类加入白名单。更细粒度的做法是只友元某一个成员函数,但语法更绕,且要求该成员函数所在类已经声明:
// 适用于 C++17
class Account;
class Auditor {
public:
static double peek(const Account& a);
};
class Account {
public:
explicit Account(double b) : balance_(b) {}
friend double Auditor::peek(const Account& a); // 友元成员函数
private:
double balance_ = 0.0;
};
double Auditor::peek(const Account& a) {
return a.balance_;
}顺序很关键:Auditor必须先完整定义,才能用Auditor::peek这种限定名。若只是friend class Auditor;,则可以先声明class Auditor;再在Account里授予友元。
二、static:属于类,而不是属于某个对象
static在不同位置含义不同,必须分开看。
2.1 静态数据成员
静态数据成员在类的所有对象之间共享一份存储,它不在对象布局里,sizeof一个对象不包含它。C++17 之前,类内只能声明,必须在类外定义一次:
// 适用于 C++17
class Widget {
public:
Widget() { ++alive_; }
~Widget() { --alive_; }
Widget(const Widget&) = delete;
Widget& operator=(const Widget&) = delete;
static int alive() { return alive_; }
private:
static int alive_; // 声明
};
int Widget::alive_ = 0; // 定义,只能出现在一个翻译单元如果这个头文件被多个.cpp包含,而定义写在头文件里,就会违反单一定义规则(one definition rule, ODR),链接期报重复定义。C++17 引入了内联变量,可以直接在类内写inline static int alive_ = 0;,省去类外定义。写库代码时这很方便,但要注意它要求 C++17。
2.2 静态成员函数
静态成员函数没有this指针,因此:
- 不能声明为
const、volatile或引用限定; - 不能直接访问非静态成员,因为没有对象可依附;
- 可以通过
Widget::alive()调用,也可以通过对象调用(但前者更清楚); - 可以访问私有静态成员。
一个常见设计是把工厂方法或工具函数写成静态成员。它和命名空间里的自由函数相比,优势主要是名字归类清晰、能访问私有成员。
2.3 函数内的静态局部变量
函数内的static变量在第一次执行到声明处时初始化,之后跨调用保持值。C++11 起,其初始化是线程安全的(编译器通常用一次性守卫实现),这使它成为实现懒初始化单例的常见手段:
// 适用于 C++17
Logger& logger() {
static Logger instance; // 首次调用时构造,C++11 起线程安全
return instance;
}但「初始化线程安全」不等于「之后的操作线程安全」。如果Logger的方法会被多线程同时调用,内部仍需同步。
2.4 命名空间作用域的 static
在命名空间作用域写static int counter;,含义是内部链接(internal linkage),该名字只在当前翻译单元可见。现代 C++ 更推荐用匿名命名空间表达同样的意图,语义更清晰,也不与类内static混淆。
三、const:接口上的承诺
3.1 const 对象与 const 成员函数
在成员函数参数列表后写const,表示该函数不会修改对象的非mutable成员,也不会调用其他非 const 成员函数。这个承诺由编译器检查,不是注释。
// 适用于 C++17
class Box {
public:
int width() const { return width_; } // 可被 const 对象调用
void set_width(int w) { width_ = w; } // 不可被 const 对象调用
private:
int width_ = 0;
};对一个const Box b;,只能调用width(),不能调用set_width()。若在width()里写width_ = 1;,编译会直接报错。这正是 const 正确性(const-correctness)的价值:把「只读」写进类型,让误用在编译期被发现。
3.2 mutable 的用途
mutable允许在 const 成员函数里修改某个成员,用于「逻辑上不改变对象可观察状态」的缓存、互斥量、惰性求值标记等:
// 适用于 C++17
class Sensor {
public:
int value() const {
if (!cached_) {
cached_value_ = read_hardware();
cached_ = true;
}
return cached_value_;
}
private:
int read_hardware() const { return 42; } // 示意
mutable int cached_value_ = 0;
mutable bool cached_ = false;
};mutable不是「绕过 const 的后门」,而是一个需要谨慎使用的设计选择。上面的缓存在多线程下并不安全,因为cached_与cached_value_的读写没有同步;若对象会被多个线程共享,需要额外加锁,而std::mutex本身通常也声明为mutable,才能在 const 成员函数里加锁。
3.3 指针与引用的 const 位置
const出现在*左边还是右边,含义完全不同:
| 写法 | 含义 | 能否改指向 | 能否改所指的值 |
|---|
const int* p | 指向 const int 的指针 | 能 | 不能 |
int* const p | const 指针,指向 int | 不能 | 能 |
const int* const p | 两者都 const | 不能 | 不能 |
const int& r | 绑定到 const 的引用 | 不适用 | 不能 |
读法是从右往左:int* const是「const 的指针,指向 int」;const int*是「指针,指向 const int」。引用本身一旦绑定不可更换,所以只有「引用到 const」这一种写法。
3.4 const 与重载
成员函数可以仅凭末尾的const重载,编译器根据对象是否为 const 选择版本:
// 适用于 C++17
#include <cstddef>
#include <vector>
class Buffer {
public:
explicit Buffer(std::size_t n) : data_(n, '\0') {}
char& at(std::size_t i) { return data_[i]; }
const char& at(std::size_t i) const { return data_[i]; }
private:
std::vector<char> data_;
};非 const 对象调用非 const 版本,返回可写引用;const 对象调用 const 版本,返回只读引用。若只提供 const 版本,非 const 对象也只能拿到只读引用,会限制可用性。
完整示例:三个关键字一起用
下面的账户类把静态计数、const 成员函数、友元输出和友元类放在一个例子里。为让计数准确,拷贝构造被显式删除,避免「复制对象却没有增加计数」。
// 适用于 C++17
#include <iostream>
#include <string>
#include <utility>
class Auditor;
class Account {
public:
Account(std::string owner, double balance)
: owner_(std::move(owner)), balance_(balance) {
++count_;
}
~Account() { --count_; }
Account(const Account&) = delete;
Account& operator=(const Account&) = delete;
const std::string& owner() const { return owner_; }
double balance() const { return balance_; }
void deposit(double amount) {
if (amount > 0.0) {
balance_ += amount;
}
}
bool withdraw(double amount) {
if (amount <= 0.0 || amount > balance_) {
return false;
}
balance_ -= amount;
return true;
}
static int count() { return count_; }
friend std::ostream& operator<<(std::ostream& os, const Account& a);
friend class Auditor;
private:
std::string owner_;
double balance_ = 0.0;
static int count_; // 声明
};
int Account::count_ = 0; // 定义
std::ostream& operator<<(std::ostream& os, const Account& a) {
os << a.owner_ << ": " << a.balance_;
return os;
}
class Auditor {
public:
static double total(const Account& a) {
return a.balance_; // 友元类可访问私有成员
}
};逐行核对:
owner()返回const std::string&,避免拷贝,同时承诺调用者不能通过返回值修改内部字符串。count_是私有静态成员,构造时自增、析构时自减;拷贝被删除,所以不会出现「复制后计数不变」的问题。operator<<在类内声明为友元,类外定义时不写friend,签名与声明一致。Auditor::total能读a.balance_,是因为friend class Auditor;授权了整个类。Account没有提供默认构造,创建对象必须传两个参数,避免出现「空账户」。
static int count_的定义必须恰好出现一次。若这段代码放在头文件里被多个翻译单元包含,应改成 C++17 的内联静态成员:
// 适用于 C++17
inline static int count_ = 0; // 类内定义,无需类外再写一遍常见坑点
- ❌ 在类外定义友元函数时又写一遍
friend。
✅friend只出现在类内的声明中;类外定义按普通函数写,签名保持一致即可。
- ❌ 以为友元关系会传给派生类,于是让派生类直接访问基类友元类的私有成员。
✅ 友元不被继承、不传递;需要访问就在对应类里单独声明。
- ❌ 把静态数据成员的定义写进头文件,多个
.cpp包含后链接报重复定义。
✅ 头文件里只放声明,定义放到一个.cpp;或者用 C++17 的inline static在类内定义。
- ❌ 在
static成员函数里直接写balance_ += 1;或使用this。
✅ 静态成员函数没有this;要么传入对象引用,要么只访问静态成员。
- ❌ 在
const成员函数里修改普通成员,靠「编译器也许不管」蒙混。
✅ 不允许就是不允许;确实需要缓存时把成员声明为mutable,并想清楚线程安全。
- ❌ 把
const int* p和int* const p当成一回事。
✅ 从右往左读:前者不能改所指的值,后者不能改指向;两者都 const 就写const int* const。
- ❌ 认为
const变量一定能用在case标签或数组长度里。
✅ 只有常量表达式才行;需要编译期常量用constexpr,const只保证运行期不可修改。
- ❌ 在
const成员函数里返回内部成员的裸指针或非 const 引用,把只读承诺泄漏出去。
✅ 返回const引用或值;若必须返回可写引用,就不要把该函数声明为const。
总结
| 关键字 | 核心作用 | 常见位置 | 关键限制 |
|---|
friend | 授权非成员访问私有成员 | 类内声明 | 不互惠、不传递、不继承 |
static | 成员属于类而非对象 | 数据成员、成员函数、局部变量 | 静态函数无this;数据成员需定义 |
const | 承诺不修改可观察状态 | 变量、参数、成员函数、指针 | 只约束被标注的层级;mutable可例外 |
三个关键字分别对应「访问权」「归属」「可变性」三个维度。写类时先想清楚哪些状态属于对象、哪些属于类,哪些接口应当只读,再把友元收窄到真正需要的函数或类。这样得到的接口比「到处 friend、到处 static、到处 const」更容易维护,也更不容易在重构时出错。本文示例按 C++17 语义书写,未编译运行,落地前请结合自己的编译器和测试核对。