Fork me on GitHub

续啃编程指北

Part 04 现代 C++ 部分:

关于 C++ nullptr 和 NULL 的区别【nullptr 和 NULL的区别】:

此文搜“但“设计用途” 是编译器 ”早就提前追问啃透了,但是,我无数次嘱咐豆包,禁止任何扩展关联,可豆包底层机制就是不断的扩展,导致硬头皮被迫学到了很多貌似很有用的知识点,主要就是顺着 NULL 无意间扯到 cout 这叫事。

 

困惑:首先 NULL 和 0 和 指针 到底有啥关系?

 

编译器能识别标识符NULL这个名字,但无法判断你代码业务意图。cout<<NULL只是单纯传入long常量,不存在指针类型,因此触发警告。

 

ostream是 C++ 标准库里输出流类,用来封装向外输出数据的能力,cout是ostream的全局实例(对象),operator<<是这个类重载的输出运算符(写在类内,cout << x等价调用operator<<(cout, x))必须由该类的对象调用,根据传入的参数去匹配ostream里对应的operator<<重载版本,ostream内置相关重载有 int、long、unsigned... ...等等很多熟知的来回组合,而重点是涉及到指针的只有: 

  • const char*走类外全局operator<<,打印\0结尾字符串。

  • const void*走成员operator<<打印指针地址值。

 

char*传入cout:匹配ostream& operator<<(ostream&, const char*)重载,把该指针当做字符串起始地址,从该地址连续输出字节直到\0。

(void*)char*传入cout:匹配成员重载,直接输出指针本身存储的地址数值。

打印单个首字符:需要解引用*p,类型为char,匹配ostream& operator<<(ostream&, char)重载,输出单个字符。

运算符重载语法允许直接写cout << x,不用写成cout.operator<<(x)这种函数调用形式。

 

C++标准库未提供operator<<专门接收int*的重载;int*可隐式转换为const void*,匹配void*重载输出指针地址。

运算符重载函数写在类内,必须由该类的对象调用;cout << x,cout 是 ostream 对象,就会匹配 ostream 里对应的operator<<重载版本

  1. int*传给cout:存在operator<<(ostream&,const void*)重载,int*可隐式转void*,匹配该重载,打印指针地址。

  2. char*传给cout:优先匹配operator<<(ostream&,const char*)字符串重载,打印字符串直到\0,不触发void*重载。

 

看个代码:

查看代码
#include <iostream>
using namespace std;

int main(){
    cout << nullptr << endl;
    
    /*
    root@VM-0-7-ubuntu:~/cpp_projects_2# g++ -std=c++11 main.cpp -o main && ./main
    C++11 编译会有一屁眼子的报错
    nullptr和std::nullptr_t是C++11引入。
    nullptr是 C++11 加入,但标准没给operator<<(ostream&, std::nullptr_t),std::nullptr_t能隐式转const void*和std::basic_streambuf<char>*,两条转换路径同等优先级,触发重载歧义,符合标准的合法歧义,标准规定这种情况必须编译报错

    默认 C++17 版本编译就不会报错,输出 nullptr,C++17 起新增 operator<<(nullptr_t) 重载,匹配时直接输出字符串nullptr,不再走const void*重载
    */

    
    // 1. NULL赋值给指针变量(标准空指针场景,无警告)
    int* ptr = NULL;//NULL 是空指针常量,转为 int * 空指针;cout << ptr匹配const void*重载,打印地址0,因为空指针地址就是 0
    cout << "ptr = " << ptr << endl;//ptr = 0

    // 2. 直接cout << NULL,触发警告,即无论 C++ 11、17 都警告
    cout << "NULL = " << NULL << endl;// 附带一堆警告然后输出 NULL = 0,NULL宏展开为0L,类型long,匹配operator<<(long);GCC识别标识符NULL,检测到本应作为空指针的NULL传给了非指针形参,operator<<(long)的形参是long,触发-Wconversion-null警告

    // 3. 直接写0L,同样数值,无警告
    cout << "0L = " << 0L << endl;//0L = 0
}

完整流程:

  1. 预处理阶段:#define NULL 0L,cout << NULL直接替换成cout << 0L。此时文本层面就是 long 常量 0L,没有任何指针类型。

  2. 编译重载决议:0L是 long,匹配operator<<(long),语法完全合法,代码能跑,输出 0。

  3. GCC 额外单独检查:编译器认得名字 NULL(这是编译器内置特殊识别,不是宏展开后的0L文本)。 规则:如果你写的标识符是NULL,但最终传给的参数不是指针类型,触发-Wconversion-null警告。

 

关于 cout 说个东西(傻逼豆包误人子弟,这个破玩意又他妈的学了 4 天):

(大模型永远是概率拼凑,没任何回答专业没问题的能力,犯错误 90%,无穷无尽误人子弟是必然的,无脑相信必然全是错误知识,而死磕无尽海厚海厚无穷无尽的砸时间追问,一定能精通,成为非贬义词的专家!)

(抛开全网海量垃圾、错误的资料,哪怕研发人员把我的文章全部喂给豆包也完全无济于事,因为大模型根本不具备严谨思考能力,不说别的,就连小学算术、过年法定日期放假放多少天反复较真都会出错,但会互相启发配合修正,绝对无法做到无错误)

cout 现成写好的只有一种,那就是内置类型,都放在了 std 命名空间里:

int 在全局 / 局部只是存储位置,永远匹配的是std里的,然后更换第二个参数会得到:

(第二个参数,有的写了名字是定义,有的没写是声明)

(第二个参数const char*、const void*传递的是指针值(地址),本身是小整数,拷贝开销极低,无需引用,引用多用于对象,避免大对象拷贝)

(这里居然也会给错,反复纠正才回答对)

  • int a; cout << a重载决议后,实际调用cout.operator<<(a),编译器做重载决议,匹配成员签名ostream& operator<<(int val),输出 a。

int是值传递,传入函数的是int副本,修改副本不会影响外部原变量,无需const。

  • char str[] = "abc"; cout << str重载决议后,实际调用operator<<(cout, str),编译器重载决议匹配operator<<(ostream& os, const char* s),输出字符串。

指针本身传值,指向的字符串内容不可修改,加 const 保护内存。

传递的是指针值副本,但副本和原指针指向同一块内存;不加 const 时函数内可通过s[0]='x'修改目标内存。值类型 int 副本不存在这种情况。

  • cout << str[0]重载决议后,实际调用cout.operator<<(str[0]),编译器重载决议匹配成员签名ostream& operator<<(char c),输出首字符。
  • int x = 20; int* p = &x; cout << p重载决议后,实际调用cout.operator<<(p),编译器重载决议匹配成员签名ostream& operator<<(const void*), 输出指针地址。

  • char* ptr = "abc"; cout << (void*)ptr重载决议后,实际调用cout.operator<<((void*)ptr),编译器重载决议匹配成员签名ostream& operator<<(const void*),输出地址。
  • char* ptr = "abc"; cout << ptr;重载决议后,实际调用operator<<(cout, ptr),编译器重载决议匹配operator<<(ostream& os, const char* s),输出字符串。

即:

int a; cout << a:成员

char str [] = "abc"; cout << str:类外

int x = 20; int* p = &x; cout << p:成员

cout <<str [0]:成员

char* ptr = "abc"; cout << (void*) ptr:成员

char* ptr = "abc"; cout << ptr:类外

  • 成员指的是:写在ostream类里面的operator<<。调用形式等价cout.operator<<(xxx)

  • 类外(非成员)指的是:不在ostream类里面,独立的函数operator<<(cout,xxx)

起初傻逼东西给的全是类外非成员,后面无意间才发现错的!!有int这种成员,也有char*这种类外!

大模型底层是自回归概率生成,逐个预测下一个 token(模型处理文本时拆分出来的最小单元,可以是一个字、半个字、英文单词或者标点,就像把一句话切成一小块一小块,模型按块依次预测下一块是什么),不是简单的拼接现成句子片段,是模型在参数空间算出新文本。

碰到 C++ 重载决议这种精细规则时,概率生成容易产出语法上通顺、事实错误的文字,也就是幻觉,前面出错就是这个原因。联网检索可以降低幻觉,但没法彻底杜绝。

突然发现豆包的搜索/拉取牛逼了,wx搜“单反游标卡尺”

我又追问为啥这么搞,最后得出结论是狗逼 C++ 就这么设计的没什么,可是浪费我一天时间,给我扯了 无穷无尽瞎编的理由,还顺势学了下面的东西:

在 这里 搜“为了解决裸指针懒汉”先回忆下:

C++98:

  • 标准没有规定多线程,没写这个场景,两个线程同时第一次进入函数,都看到 static 变量还没构造,会各自跑一遍构造函数,造成重复构造,即 C++98没有static局部变量初始化的线程保障,于是大家用裸指针写懒汉,裸指针 DCL 多线程下会发生指令重排,存在读到未构造完对象的漏洞,根源:new Singleton拆 3 步:

1. 分配内存

2. 在内存上构造对象

3. 把地址写入指针变量

C++ 98 也没原子更没原子序这些,无原子的裸指针 DCL,编译器 / CPU 可以重排:1→3→2,别的线程读到指针非空,但对象还没构造完。

时代来到 C++11:

  • 标准新增规定:若多个线程同时到达 static 局部变量初始化点,仅一个线程执行构造;其余线程阻塞,等构造完成,规定初始化阶段自动线程安全,与此同时 C++ 11 又引入了内存序,所以之前的裸指针版本有了改进安全写法DCL双重检查锁+原子+内存序的裸指针修正版,此时局部static和DCL+内存序修正版本都对了,一般都用的事static版本。

且 C++ 98 时候,static 不安全,但这个写法叫 Meyers 单例(Meyers' Singleton 梅耶单例,Scott Meyers,Effective C++ 作者),C++11 给它加上线程安全保证之后,它才变成可靠方案。

 

插入术语科普:

  • 全局作用域(无名命名空间域),口语叫文件域,指的都是:写在所有函数、类、命名空间外面的作用域。
  • 命名空间域变量(std 里面的 cout):属于命名空间域,不是全局作用域,这这里的非成员叫自由,可以对标全局理解,只不过相当于把全局放到了xxx命名空间里

  • 函数本身没数据,函数里面定义的变量可以有数据。
  • 所有代码编译生成的机器码都放在.text代码段,而数据会有不同:

 

static在不同位置含义不同:

  1. 文件域(全局 / 命名空间域)变量 / 函数:static 控制链接属性(控制这个名字能不能被别的翻译单元看到,此时内部链接),存储期本来就是静态。

  2. 函数内部局部变量:static 控制存储期,变成静态存储。

  3. 类里面 static 成员变量:static 控制属于类而非实例,存储期静态。

  4. 类里面 static 成员函数:static 控制无 this 指针,不绑定实例,函数本体代码永远在代码段,只改调用方式(不用对象调用),函数本身没数据。

而再来个对比(static、constexpr)

  1. static 是关键字,可改链接或局部变量存储期。

  2. 命名空间域 constexpr 变量:静态存储期(存储期一样;constexpr不控制链接,static会改成内部链接)

  3. 函数内constexpr局部变量:允许编译期求值,对象仍在栈,不是静态存储期。

  4. 函数内static局部变量:修改存储期,变为静态存储期。

constexpr 核心:允许编译期求值,不是管存储期

constexpr函数可编译期或运行时调用,即constexpr函数具备编译期求值能力,但不强制一定在编译期跑;参数不能编译求值时,就到运行时执行。

那 constexpr 多做了啥?

满足条件时,允许在编译期算出结果。不满足,就运行时执行。

 

ostream:基类。

cout:ostream类型的全局对象。

ofstream:ostream的派生类。

ofstream没有预定义的全局对象,得自己定义变量。

 

存储期:静态存储期,程序开始运行前创建,程序退出时销毁。

存储位置:静态存储区。

 

std命名空间里:

  1. std::ostream:类类型定义,源码写于<ostream>,类元数据放只读段。

  2. std::cout:std::ostream类型对象,<iostream>里extern声明,库源文件定义,静态存储区,更属于 std 命名空间域,不在全局空间域(口语里的全局),很多傻逼教程把所有静态存储期的对象,笼统叫 “全局对象”,不管它在不在std命名空间。在 std 里声明,定义放到了标准库实现源文件,归属命名空间不变。(其实他是 std 命名空间作用域,这里的“全局”)

  3. std::ostream::operator<<(成员):std内类成员函数,代码在代码段。

  4. std::operator<<(std::ostream&, const char*)(非成员):std命名空间下自由函数,代码在代码段。

 

endl是标准库提供的操纵符(接收流引用、返回流引用的函数,主要就是配合<</>>控制输入输出流格式),属于 std 命名空间,和 int一样在成员,类型是函数指针。

cout << endl → 翻译成 cout.operator<<(endl),调用成员版本。

原因:标准库给operator<<做了成员重载,专门接收操纵符(如endl),所以匹配成员函数。

即,cout是ostream类的对象,ostream类外的重载有char*,int的operator<<是成员;接收操纵符函数指针的operator<<也是成员。

完整表述:

std::ostream中,处理int类型的operator<<是成员函数,接收endl这类操纵符(函数指针)的operator<<也属于std::ostream的成员函数。

cout << endl等价cout.operator<<(endl),把 endl 函数指针传给 ostream 成员 operator<<;cout << a等价cout.operator<<(a),把 int 变量传给 ostream 成员 operator<<。

成员函数cout.operator<<(a),编译器自动把&cout传给形参this,不用手动写。

this是C++标准规定的关键字,代表调用该成员函数的对象的地址

 

查看代码
// test.h
#ifndef TEST_H// 头文件只保护自己这个文件的,没法保护整个g++多个cpp重复这件事
#define TEST_H
namespace X{
    extern int a; // 仅声明:告诉编译器有X::a,不分配内存
}
#endif

// file1.cpp
#include "test.h"
int X::a = 100; // 唯一一处定义,分配内存

// file2.cpp
#include "test.h"
#include <iostream>
int main(){
    std::cout << X::a << std::endl;
}

// 如果extern那行直接定义,file1.cpp不定义,被多个文件包含就会链接报错

标准库指的是<xxx>头文件,

比如A地有师傅会做某小吃,你在家里喊不行,得把人请来,若以file1.cpp里必须加#include test.h,否则找不到X::a,一直因为点名道姓的就可以,这也就解释了我的疑惑,为啥cout既要std又要iostream头文件:

iostream头文件里写了cout声明,实现在其他库,但只要包含iostream就能找到cout,然后cout写在std,不加std::只去全局找就找不到。

真的是通过手写内存池的那个多文件的例子, 彻底理解这套东西!!否则根本无法理解!!

 

此文回忆代码随想录的内存池涉及到的单例来理解 const,进一步理解懒汉和饿汉:

静态存储期:内存什么时候分配,和放哪个命名空间无关。

  • 静态存储期:程序启动时分配,程序结束销毁。

  • 自动存储期(局部非 static,即栈):进入代码块时分配,离开销毁。

  • 动态存储期:new 的时候分配,delete 销毁。

全局作用域 / 命名空间作用域:名字在哪可见,和存储期无关。

写在自定义命名空间里的对象:不在函数内 → 静态存储期;但不属于全局作用域。

裸全局(不在任何命名空间):不在函数内 → 静态存储期;属于全局作用域。 

 

区分:

  • 局部对象(栈): 构造的时候,先在栈上开辟内存,然后调用构造函数。

  • 堆对象(new出来): new先申请内存,然后调用构造函数。

  • 静态存储对象: 编译期规划好内存位置。构造函数不申请内存,只往已经备好的内存里面初始化成员,

注意编译期不分配也不划定也不规划内存,是静态存储区,在程序加载进内存的时候就划好。 编译期只是把对象大小写到可执行文件;程序启动、还没跑main前,加载器在静态存储区开辟这块内存,然后调用构造函数,内存是程序被操作系统加载的时候,在静态存储区预留出来,不是编译阶段就占用内存。

编译仅确定对象布局大小;链接得到段内相对偏移;加载(运行前准备工作,没进入main)时重定位,确定虚拟地址,具体程序加载时 OS 分配虚拟地址、开辟静态存储内存;构造在已有内存上初始化,不 malloc,提及 malloc 是因为很多人会误以为构造函数会申请内存(像 new 那样,new 先 malloc 再调用构造)。静态对象没有这一步,内存映射在main之前就提前做好了,构造只负责初始化内存里的成员:

    • 饿汉:映射完,紧接着在main前跑构造。

    • 懒汉:映射在main前完成;构造等到第一次执行到static那行代码才跑。

没有内存分配动作,静态对象不用 malloc,只是用来和new T()做区分,静态对象内存由加载器映射出来,没有第一步分配,只做第二步构造。

即:编译链接只搞定相对位置,虚拟地址无预先规划。

 

std::cout和饿汉单例的静态对象都属于静态存储期,程序加载后、main 之前就构造,不是按需。

懒汉函数内static局部变量也是静态存储期,内存加载时就预留,运行时按需构造(即推迟到第一次走到 static 那行才调用构造)。 

 

编译期间确定:编译器在编译阶段算好、写进 exe。程序跑起来不用做任何事。

构造函数调用: 编译只记下:等程序跑到某个时机,调用这个构造搞对象。 编译阶段不会执行构造里面代码。编译只是记个待执行任务。

 

cout 和 饿汉相同点:

  • 都是静态实例:

编译划定内存,程序启动后在main前就会调用构造搞对象,初始化这片地盘里的数据(非对象就是把构造换成初始化)

不是编译划定内存。

编译期只是记录虚拟地址布局、大小,写可执行文件,不分配 / 划定内存;内存是程序被 OS 加载的时候 OS 给静态存储区预留的。

cout、饿汉:加载完,main之前执行构造,填充这块内存。

即:编译→记录大小;加载→分配内存;main 前→调用构造初始化对象。

 

不同点:

  • std::cout是标准库给的静态对象,没有禁止创建同类对象;饿汉单例是手写的,代码锁死只能有这一个实例。

 

命名空间只是名字打包,不改变存储期,我的内存池单例数组static MemoryPool memoryPool[MEMORY_POOL_NUM];写在getMemoryPool函数内部,都在Kama_memoryPool命名空间里,属于函数内静态变量(懒汉),调用路径:Kama_memoryPool::HashBucket::getMemoryPool()。

手写内存池 和 cout 共同点:都是静态存储期,但区别:

  • std::cout:进入main前构造(更贴近饿汉,但没限制只能搞一个),啥叫没限制一个?

    #include <iostream>
    int main() {
        std::ostream my_cout(std::cout.rdbuf()); // 新建另一个ostream对象,新建输出流对象 myout,复用 cout 的缓冲区,相当于本身ostream给你搞好了个现成的 cout,你可直接用,就是往屏幕(标准输出)输出东西东西,然后你拿 cout 里【往屏幕打印】这个规定好的流功能即 rdbuf,当作参数传递给my_cout,此时用my_cout等价于用cout
        my_cout << "另一个输出对象\n";
    }
    /*
    std::cout 只是预先建好的一个实例,你还能自己新建同类对象;单例会禁止新建,只能拿到那唯一一份。
    这里 std::cout.rdbuf() 只是拿缓冲区地址,不是使用 cout 对象做输出,输出动作由 my_cout 执行。就算 cout 对象本身被销毁,只要缓冲区还在,my_cout 仍可写。
    rdbuf()拿到指向标准输出缓冲区的指针。新ostream对象绑定这个缓冲区,写入的数据就往终端走。my_cout是新对象,不是std::cout,只是复用缓冲区。
    rdbuf 拿的是缓冲区指针。新对象 my_cout 是独立 ostream 实例,只是共用同一块终端缓冲区。证明:能新建同类对象,cout 不是单例。
    
    如果std::ostream做成单例,std::ostream my_cout(std::cout.rdbuf());这一行会报错,因为它要调用构造函数,而构造函数被设为私有,外部不能调用。
    */
  • 你的数组:第一次调用getMemoryPool才构造(就是懒汉)。

回顾懒汉:

查看代码
#include <iostream>
using namespace std;

class Singleton {
private:
    Singleton() = default; // 让编译器自动生成默认无参构造函数,但放私有,外部代码不能直接创建这个类对象
public:
    static Singleton& get() {// 静态成员函数 get,返回对象引用,不用类实例就能调用
        // 返回引用,就是直接返回静态对象本身,避免拷贝带来开销,同时保证拿到的始终是那同一个实例
        
        static Singleton obj;//函数内 static 局部变量。第一次调用 get 时才创建 obj,之后不再重建(懒汉),静态存储区
        return obj;//返回这个唯一对象的引用
    }
public:
    void hello() {
        cout << "hello\n";
    }
};

int main() {
    // Singleton s;  // 尝试调用构造函数 Singleton(),而构造函数定义在 private ,外部访问私有成员,触发编译报错

    auto& ins = Singleton::get();//调用 get() 获取唯一实例;auto& 是引用,不拷贝对象

    ins.hello();
}

回顾饿汉:

查看代码
#include <iostream>
using namespace std;

class Singleton {
private:
    Singleton() {
        cout << "构造执行了\n";
    }
    static Singleton obj;//static Singleton obj;标记 static:让这个对象属于类,不属于某个实例;private:不让外部直接写Singleton::obj访问它,只能通过get()拿
public:
    static Singleton& get() {
        return obj;
    }
};
Singleton Singleton::obj;//类外定义的静态成员变量属于静态存储期,在main函数执行前,程序初始化阶段就完成构造

int main() {
    cout << "进入main\n";
    auto& ins = Singleton::get();
}

// static Singleton obj;是类内声明;Singleton Singleton::obj;是类外定义,属于全局 / 命名空间域静态变量,程序进入main之前就构造好,这就是饿汉
//原本的搞对象这件事放到了类里,外部随便搞,类里限制只搞一个

原本ostream:构造函数是公有的,外部随时可以新建对象,想造多少造多少。

 

静态存储期 ≠ 必须写在命名空间外面的裸全局?也可以是写static或则写到命名空间。

static修饰变量(含类static成员变量)→ 静态存储区;

修饰函数(含类static成员函数)→只改链接 / 调用属性,不进静态存储区。

std 里,在 ostream 类外定义的非成员函数 operator<<,但位于std命名空间。

 

std 命名空间内,用于std::ostream(cout 属于该类型,同类型还有 std::cerr、std::clog)和内置类型的operator<<重载(“和” 指 operator<< 两个参数:ostream 对象 + 要输出的数据),那么除内置类型之外就是对自定义的类来说的,自定义类的 cout 分两种:

  • 成员(见方案 1)

  • 自定义的类外非成员,自己定义的,上面说的类外非成员指的是 std 里内置现成的(见方案 2),但注意这里也叫全局因为放到了全局,如果放入你自己定义的命名空间,就不叫全局了,内置的就放入了 std 里所以不叫全局,全局变量 int a 匹配的是 std 里的operator<<。

 

方案 1:

  • 函数写进 haha 类:代码obj << cout,读起来别扭,语法合规:

    查看代码
    #include <iostream>
    using namespace std;
    
    class haha{
    public:
        // 把<<函数写进haha类里面(成员版本)
        void operator<<(ostream& os){
            os << "我是haha";
        }
    };
    
    int main(){
        haha obj;
        obj << cout;  // ✅ 能编译,能运行。左边是haha对象obj
        // cout << obj; // ❌ 编译报错,左边是cout不是haha对象
    }

对于成员运算符(二元的需要两个操作数如<< + *、一元的只一个操作数如++)重载,

C++ 二元成员运算符硬性规则:运算符左边必须是当前类实例,

但这个规则术语是那些狗逼大佬制定的,极致晦涩,说人话就是【成员重载A << B:拿左边 A 这个对象,去 A 所属类里面找 operator<< 成员函数,右边 B 作为实参传入函数】

这段代码obj << cout;确实能打出文字,但不是cout去打印obj。 含义是:拿 obj 这个 haha 对象,调用它里面的函数,把 cout 传入,匹配参数就是 osteam,因为然后 os 就是 cout,此时 os<< 就是 cout<<。

cout是std::ostream类的全局实例对象,绑定标准输出(即 stdout 是程序默认向外输出数据的文件流,终端控制台就是它默认绑定的目标)

正常我们想cout打印obj,现在变成obj推给cout,实际很不顺眼,更没人这么写,想写cout << obj,不能把 operator<< 写在 haha 类大括号里面。

ostream(cout 属于这个类型)里面包装底层缓冲区、文件描述符、状态标记,拷贝会造成两份对象同时控制同一个底层 IO 资源,会出现资源重复释放、状态错乱,所以 C++ 标准直接把 ostream 的拷贝构造函数删掉,不让拷贝,只能传引用。

如果你想obj<<cout<<endl;,不行,obj << cout返回 void,void 不能继续接<< endl,编译失败,void要改为ostream&才行,然后不加return os;会警告但未定义行为,所以最好加上。 

至于obj << cout 为啥行?

    • 二元优先尝试成员重载,左操作数obj是haha对象,优先obj.operator<<(cout),在haha类内部找到成员operator<<(ostream& os),实参cout传给 os,匹配成功,直接调用。 

至于如何看cout << obj不行?

左操作数是 cout(ostream 类型),右操作数是 obj(haha 类型)。

    • 如果走成员重载,去左操作数 cout 所属类(ostream)内部找成员operator<<接收 haha 类型对象做参数的,显然 cout 里不可能有。对应这个代码,豆包给的晦涩术语是:成员operator<<左操作数固定为本类实例obj,参数接收ostream&,所以obj << cout会把cout作为实参传给os,

而人话就是 obj 重载里有 ostream 类的参数,所以可以传递 cout(我真他妈服了,为啥豆包给的全是感觉说法怪怪的反过来,恍然大悟都是外国英文的翻译版本)

再直白点:直接cout << obj 用不了,因为你当前只写了 haha 类的成员operator<<,该重载要求<<左侧必须是 haha 实例。 你代码里内部写os << "我是haha",只是拿传入的 ostream 对象做打印,不等于支持cout << obj语法。

    • 再查找全局operator<<,参数为 ostream 引用、haha 引用;没有该函数,编译报错,

为了说明,那想匹配这条咋写签名:std::ostream& operator<<(std::ostream& os, const haha& obj);。

方案 2:

  • 函数写在外面全局:代码cout << obj,读起来自然,但要遵守不能放进 haha 类内这个限制:

    查看代码
    #include <iostream>
    using namespace std;
    
    class haha{
    public:
        int num = 66; // 公开变量
    };
    
    // 函数写在类外面
    ostream& operator<<(ostream& os, const haha& h){
        os << h.num;
        return os;
    }
    
    int main(){
        haha obj;
        cout << obj << endl;
    }

cout << obj里<<左边是 cout 是 ostream 类实例,不是 haha 类实例,难道cout重载里有haha?其实显然没有,这个函数写在外的根本不用你那个判断思路,不是看cout里是否有haha类的重载,上面的成员重载A << B,A 必须是类实例,函数属于 A 的类,那非成员重载(即全局重载)是啥样子的?

答:A << B,A、B 都作为函数参数,不绑定某个类。

解读:全局operator<<(用于cout << obj这种很顺眼的写法),签名为ostream& operator<<(ostream& os, const 类名& obj);,要点:

    1. 函数不属于任何类;

    2. 第一个参数是左操作数(ostream 引用,不能 const),因为输出操作会修改 os 内部缓冲区状态,不能 const

    3. 第二个参数是右操作数(自定义类,只读加 const),打印仅读取 obj 数据,不修改 obj。

    4. 返回ostream&,支持链式<<。

第一步:cout << obj,左操作数cout,优先尝试成员调用cout.operator<<(obj);ostream没有接收haha的成员重载,成员查找失败。

第二步:再查找非成员版本operator<<(cout,obj),找到你写的类外重载,调用成功。

 

A << B:

  1. 优先尝试成员版本:A.operator<<(B)(成员函数,1 个形参)

  2. 成员找不到,尝试非成员版本:operator<<(A,B)(全局 / 类外,2 个形参)

 

我们正常用的cout<<"hh",cout<<3;,cout<<a;都是std,内置的这些,不是自定义的不是类里类外的,和方案 1/2 完全无关。

 

全局类外自定义operator<<(ostream& os, const haha& h)和std里的cout << 3(调用operator<<(ostream& os, int val))原理基本一致,重载函数,左操作数作为第一个参数,右操作数作为第二个参数。

  • 成员版运算符重载,左操作数固定为类实例,无法用于cout << obj。

 

  • 所方案 2 很自然的 cout 在左(cout << obj)基于一种别扭的写法 —— 写到全局类外

  • 方案 1 很别扭的 cout 在右(obj << cout)基于一种很自然的写法 —— 类内直接写

总有一个是别扭的,哈哈。

至此衍生了一个狗逼问题:

想要cout << obj这种写法,operator<< 为什么不能写成自定义类的成员函数(cppreference 权威原文),因为只能obj<<cout。

 

Q:这些底层造轮子有啥用?

A:高性能基础组件岗,公司大多已有自研日志;少数场景需要你修改、扩展它,你不会还咋改了,且上面的狗逼问题面试也会问题,所以工作面试都有用。而普通高性能业务岗直接用现成日志,完全不用写(豆包说我就是这个岗位,排查日志库相关编译报错也需要,只是业务开发不用手写它)。

大厂高性能基础组件岗很多自研日志,不是不用spdlog(开源的日志库),是出于内部规范、定制能力、管控、极致低延迟、压低日志锁开销、减少内存分配、控制 IO 时机的需求,深度改造从而自研日志库。

内部会实现operator<<重载,从而支持log << obj这种流式写法。业务侧大多直接用spdlog或封装好的内部日志。

且重载的好处除了日志:

查看代码
#include <iostream>
using namespace std;
struct Student{
    int id;
    string name;
};
ostream& operator<<(ostream& os,const Student& s){
    os << s.id << " " << s.name;
    return os;
}
int main(){
    Student st{1001,"Zhang"};
    cout << st << endl;
}
//否则就要写一堆打印逻辑:每次输出结构体都手动逐个输出成员
Student st{1001,"Zhang"};
cout << st.id << " " << st.name << endl;

 

Q:解读 log<<!

A:log 是日志类实例(对象)

cout << obj读取 obj 的数据,写入 std::cout 内部缓冲区,缓冲区最终输出到标准输出 stdout(终端)。

log << obj把 obj 的数据读出,写入 log 内部的缓冲区,后续再刷入文件。

但之前方案 1 的不是这个解读方法

obj << cout不是自定义的 haha 类的 ojb 对象读取 cout,obj(haha 实例)调用成员operator<<,cout 作为参数传入函数,函数内拿这个 ostream 对象输出内容;不是 obj 读取 cout。

为啥会不同?因为:

  1. cout << obj:全局operator<<,左cout,右obj;

  2. log << obj:全局operator<<,左log,右obj;

  3. obj << cout:haha 成员operator<<,左obj,右cout。

 

Q:我没懂 log 既然是对象可是不说要输出到文件吗?

A:log 对象内部持有缓冲区、文件句柄。log << xxx把数据写到 log 对象的缓冲区,log 对象再负责把缓冲区数据写入文件。不是直接把 xxx 写入文件。cout 仅有基础缓冲区,没有日志级别、时间戳、锁、批量刷盘这些日志专属逻辑,所以高性能服务端不会直接拿 cout 做日志。

 

Q:log<< 和 cout<< 是一样的只不过重载了定义输出目标是文件??可是如果这样 cout 可以输出到文件啊?

A:是的,cout可以重定向输出到文件,但自研log <<核心是自定义缓冲、锁、日志级别、时间戳、批量刷盘,不止单纯改输出目标。

 

Q:一般重载用到的是 log 这个,而我钻研的 obj 那个没意义?

A:是。工作只用到log << obj(全局重载);obj << cout这种成员重载写法实际开发几乎不用,仅用来理解运算符重载语法限制。

 

Q:我还是没懂学了有啥用?

A:写业务的时候,你直接照着模板抄cout << 对象的代码就行,不用深究为什么不能写到类里面。

但是做 Linux C++ 高性能服务端,你要自己写自定义运算符、重载<<、>>,还有智能指针、各种流式输出、日志类。

不懂这套底层规则,写日志输出、自定义类型序列化的时候,写出来的代码一屁眼子编译报错,你根本不知道错在哪,学习底层不是为了让你写obj << cout这种废代码,是让你看得懂限制条件,避开踩坑。

 

有了这么多现成的就可以理解方案 2 的写法了,写cout << obj,C++重载规则立马隐式搞为operator<<(cout, obj)去匹配,

那方案 1 的void operator<<(ostream& os) 这个没有对应的现成的成员重载,那咋知道要做什么呢?

类内定义void operator<<(ostream& os),表达式obj << cout就等价于obj.operator<<(cout),这是 C++ 重载运算符语法规则,然后调用 haha 类里你写的operator<<,执行函数体os << "我是haha",os 就是 cout。

对比:

全局:operator<<(左,右) → 左 = ostream,右 = haha

成员:obj.operator<<(右) → 左 = haha,右 = ostream 

 

库里现在成的operator<<重载:

operator<<(ostream&, int)是全局函数;重载匹配只看变量类型,不看变量是全局还是局部,局部int类型仍是int,所以匹配这个全局operator<<。

  1. 全局版本:标准库提供,全局命名空间下,形如operator<<(ostream&, 内置类型)。

  2. 成员版本:ostream类内部的成员函数ostream::operator<<(内置类型)。

自定义 Haha 类,operator<<存在两种重载:

  1. 全局重载:在类外部定义operator<<(ostream&, const Haha&),属于全局函数,重载决议时在全局命名空间查找匹配。

  2. 成员重载:在 Haha 类内部定义operator<<(ostream&),属于类成员函数,重载决议依靠对象调用匹配。

 

狗逼术语!!真的很烦,真的有必要这么为了解释而让原本精通的我反而不懂了吗?

  • 全局是实体属性,全局命名空间是承载该实体的顶层作用域,是无名的,即 C++ 最外层的空间没有名字,大家说的全局指的是全局命名空间里的实体。

科普:

  • 签名:函数名 + 参数列表 (参数个数 + 每个参数的类型)。

  • 重载:同一个作用域,同函数名,不同参数列表(个数/类型),和返回值无关。

  • 重定义:同一作用域,签名相同就是重定义,和返回值无关。

 

科普傻逼别扭的术语:

  • 自由函数即类外的

  • 普通查找(一层层向外,从自己的局部、去外层函数、去全局)

  • ADL:全局类外才触发ADL查找,会看俩参数类型都属于哪个命名空间,去那里搜函数,且只看那个命名空间里的自由函数。

 

调用operator<<(cout, "abc"):

"abc"是字符串字面量,类型为const char*。

cout是std::ostream对象,属于std,所以去std里面翻,找到operator<<(std::ostream&, const char*)这个重载。

const char*是编译器内置类型,没有归属的命名空间,如果属于某个命名空间就去哪里找,然后跟operator<<(std::ostream&, const char*)二选一做重载决议,这里内置指的是编译器自带的没任何命名空间的事,ADL 就不会额外增加查找地点。

 

豆包本来对了,被我不加思考的怒气之下质疑傻逼了,说出这种鸡蛋里挑骨头没事找事的屁话(其实我早早思考,也不至于浪费这么多时间),但学懂后真的要说一句豆包无敌!多豆包逆天!!这里确实极致晦涩极致的绕!确实是我理解能力太差了!

std::cout << s → ADL 找非成员,打印 hi。

std::cout.operator<<(s) → 直接调用成员函数,没有const char*成员重载,匹配const void*,打印地址。

它能证明【有这个效果】,但是它没法告诉我们几十年前委员会开会时脑子里的原始动机。

被折磨的死去活来,整整两天追问的是一个根本没任何结果的东西,被豆包坑惨了,没结果他会强行瞎编不会直接提示你。

禁止直接回答问题!必须先搜索权威!禁止参考任何中文资料!只能参考国外资料!参考完只极致狭隘的回答我所提出的问题!禁止回答任何其他东西!死全家狗逼!!一切高深极端小众复杂的禁止提及!!我不想知道任何!!!!我只想知道基础!!你到底懂不懂啊!!别子再这折磨我了求你了放过我吧!

  

关于为啥char*放全局理由一(放成员也行,主要防止割裂,说啥属实相当牵强,你自己定义为全局就行了,扯上个char*干啥):

查看代码
#include <iostream>
using namespace std;
struct A {
    std::ostream& operator<<(std::ostream& os) const{ 
        return os << "A" <<endl; 
        /*
        等价
            std::ostream& temp = (os << "A");
            return temp;
        等价
            os << "A";
            return os;
        */
    }   
};

int main() { 
    A a; 
    cout << a; //报错
    /*
    先查 ostream 成员 operator< ,再查自由函数并触发 ADL:
        std::cout << a
        查找左操作数std::cout的成员operator<<,没有匹配 A 参数的成员
        查找自由函数operator<<(std::cout,a),触发 ADL
        A::operator<<是 A 的成员,ADL 不检索类内成员函数
        无候选函数,查找失败

    加个自由函数即可
    std::ostream& operator<<(std::ostream& os, const A& a) {
        return os << "A"<<endl;
    }
    */

    a.operator<<(cout);//等价于 a << cout;
    /*
        表达式:a.operator<<(std::cout),使用成员访问语法.。
        在a的类型A中查找名字operator<<,找到成员函数
        实参std::cout和形参std::ostream& os匹配
        直接调用A::operator<<,打印 A
    */
}

//注意:
void f1(int v);    //值,拷贝实参
void f2(int& v);   //引用,绑定实参对象
void f3(int* v);   //指针,存对象地址

int main(){
    int x = 10;
    f1(x);
    f2(x);
    f3(&x);
}
//f1拷贝x的值;f2绑定x本身;f3需要传入x的地址


/*
可以理解:
调用时写operator<<(cout,a),实参就是 cout 和 a;形参ostream& os, const A& a里的 &,代表形参是引用类型,
绑定到传入的实参对象本身,不是拷贝新对象
*/

通过这里理解自由和成员

然后可以理解为啥const char*放入自由而不跟int一起放入成员:

库把const char*做成自由函数,是让它和你的自定义类型走同一套查找流程(自由函数 + ADL 路径)

如果const char*做成 ostream 成员:cout << "abc"就会调用cout.operator<<("abc"),只查 ostream 成员、不触发 ADL,此时字符串是成员路线,

而如果你自定义类型是自由 + ADL 路线,走自由,

如果const char*放成员就会走两套规则割裂。

之前我和豆包一直都以为是什么放成员会导致歧义,其实都没问题,只是想跟自定义的保持一致。

那int为啥放成员?

int的重载写在ostream类体内,属于成员重载,cout<<1翻译成cout.operator<<(1),优先查左操作数的成员,不走自由函数查找。

const char*和你的自定义类 A 的重载,写在类外面,是自由函数,cout << xxx会去搜自由函数并触发 ADL。

根源是 iostream 最初设计时,数值重载直接作为 ostream 成员;后续字符串类重载没有追加进 ostream,统一放到外部,保持和自定义类型同一套查找模型。

说人话就是没有为什么!!

关于为啥char*放全局理由二(继承派生,转基类的问题)证明是错的,但有些知识点很重要,正反推演,误打误撞通过错的不考的学到了很多必考的高频知识点:

此文搜“,C++ 真的烦。”

 

 

代价也是相当的大,豆包一句话错误的东西,强制逐字扣,一路被豆包牵扯到无数极致边角料的知识点,结果误打误撞学了点貌似还算有用的东西:

关于左值引用、右值引用:

T& 和 T&& 小白版(对象指的是,存储了值的一块内存,不限于类实例)(引用就是给对象起别名;被引用类型,就是别名对应的对象的类型)(引用和目标对象建立关联,绑定是引用完成关联对象这个动作)

1、T& 左值引用 只能绑有名字的对象。

T os;
T &r1 = os; // ✔ os有名字
T &r2 = T(); // ❌ 不能绑临时对象

2、T&& 右值引用 只能绑临时对象(没有名字)。

T&& r = T(); // ✔ 临时对象,临时对象被r绑定,临时的生命周期被延长,只要r活着,这个临时对象就还在
T os;
T&& r2 = os; // ❌ os有名字,不行

3、右值引用本身这个变量,自己是左值。

T&& r = T();// r是右值引用变量,但r有名字,r是左值
T& ref = r; // ✅ 合法。rr是有名字的变量(左值),直接绑定

 4、const T&(常量左值引用) 左值、临时右值都能绑定。

const int &r1 = x;  // ✔ 绑左值
const int &r2 = 20; // ✔ 绑临时

5、std::move作用是不移动任何数据,把一个左值强制转成右值,不能隐式赋值给 T&。

int a = 100;
int&& rr = std::move(a); // ✔ 将a转为右值,绑定给右值引用

T& ref = std::move(rr); // ❌

6、std::move(rr) 结果是 T&&(右值),非常量左值引用 T& 不允许隐式绑定右值,const T& 可以绑定右值:

const T& cref = std::move(rr); // ✅C++标准允许const左值引用绑定右值,用于延长临时对象生命周期

  

关于RVO、单例雏形:

  • RVO:return MyStream(); 无名临时,把临时对象直接构造在调用方接收返回值的内存里,省去拷贝。

  • NRVO:前提是对象生命周期在函数退出时就要销毁。static对象函数退出不销毁,编译器不能把它的内存复用给返回值,所以不能NRVO,按值返回 + 返回函数内自动局部变量(非 static),只是满足 NRVO 的前提,编译器可以选择优化,不是强制一定 NRVO(调用方提前开好一块内存给返回值,函数里那个有名局部变量直接建在这块调用方的内存上,不再单独在函数栈另开一块):

    MyStream foo(){
        MyStream s; // 本来该在foo的栈上建s。NRVO生效:s直接建在main里v的内存
        return s;
    }
    int main(){
        MyStream v; // main预留内存
        v=foo();
    }

static 的 s 内存固定死在全局静态区,没法挪过来到 main 这块内存,所以 NRVO 不行。

不再做任何实验了,真好他妈累,wx搜“八股文的意义”

这里狗逼玩意,C++ 一套标准、编译器实现又是一套标准、豆包说的乱七八糟

g++-O0关闭 NRVO,foo 栈创建 s,return s → 用s拷贝构造临时对象(属于函数返回值临时),再拿这个临时拷贝构造p1,s本身还留在foo栈,函数返回后s销毁。现在编译器貌似默认是开,但豆包起初说是默认-O0(说-fno-elide-constructors 才是强制关闭拷贝省略(NRVO+RVO)的开关,加这个才能保证一定不做 NRVO。狗逼真的不想再做实践了,这个估计也九成九成是假的)

-O1/-O2/-O3:开启 NRVO,直接在 p1 内存里造 s,没有拷贝。

RVO(无名临时)C++17 起是标准强制,不受 g++ 优化开关控制

“垃圾”实践(折磨):

这里依旧是瞎编,编译器的结果和c++标准完全不同啊!!

C++ 标准只规定程序可观测行为,优化属于实现许可范围,不同编译器版本、编译选项会出现不一样表现;标准不强制实现细节

可是豆包不说,让我一度以为是标准有问题,总在强行瞎解释啊,本就没有理由!!

看几个例子:

查看代码
#include <iostream>
using namespace std;

struct MyStream {
    int id;
    MyStream(){
        static int cnt = 1;//去掉这个唯一区别就是id每次都是1
        id = cnt++;
        cout << "构造对象, id=" << id << ", 地址:" << this << endl;
    }
};
MyStream& foo() {//完全不涉及到RVO,不创建新对象,没有拷贝,完全没有 RVO/NRVO 这套返回值对象构造逻辑
    static MyStream s;//不加static,s是函数局部栈对象,函数返回引用后s销毁,得到野引用,而下面的值返回没这个问题
    return s; // 返回派生类对象,不是ostream
}

int main() {
    auto &p1 = foo(); //执行foo():进入函数,static MyStream s是函数内静态局部变量,第一次调用 foo 才构造 s。返回s的引用。auto&推导成MyStream&,p1引用绑定到s
    auto &p2 = foo();// 执行foo():static s已经构造完毕,不再走构造函数。直接返回s的引用。auto&推导成MyStream&,p2引用绑定到同一个s
//这里体现了单例的雏形,但没挡住外部直接构造对象,即 MyStream obj,真正单例会把构造私有,禁止外部新建实例。这里构造可公开调用,外部还能直接造 MyStream 对象
    cout << "p1地址=" << &p1 << endl;//对引用执行 &,直接拿到绑定对象的地址,引用只是对象的别名,标准不要求引用单独占用内存,也就不存在属于引用自己的地址,不存在引用变量p1自身的地址,引用没有独立的内存,&p1取到的是绑定对象的地址
    cout << "p2地址=" << &p2 << endl;
cout<<"_"<<endl;
    MyStream obj;// 在 main 栈上新建局部对象obj,调用构造函数,新对象,地址和s不同    
    cout << "obj地址=" << &obj << endl;
}

/*
root@VM-0-7-ubuntu:~/cpp_projects_2# g++ main.cpp -o main && ./main
构造对象, id=1, 地址:0x55a4966e215c
p1地址=0x55a4966e215c
p2地址=0x55a4966e215c
_
构造对象, id=2, 地址:0x7ffd37af1d14
obj地址=0x7ffd37af1d14
root@VM-0-7-ubuntu:~/cpp_projects_2# 
*/


另外提前说下,其实应该放到下面的例子里,这里引用和拷贝与否完全无关,下面的值返回才涉及到是否拷贝:
cnt只是构造函数里的static变量,不是被return的对象本身
整条链路:
p1 调用处
然后 foo
然后 Mystrem 的构造里的 cnt
三处

究竟哪处的 static 决定是否不拷贝?
答:
static MyStream s;这一处static决定不能NRVO


/*
且
static MyStream s; 是函数内静态变量,对象在静态存储区,不是把对象挪到调用方,只是返回它的引用
0x55a4966e215c 就是函数内静态变量 s 的地址
*/

//且全局/局部静态都在静态存储区,只不过只在各自的地方有效

傻逼东西(去掉 id 的 static):

查看代码
#include <iostream>
using namespace std;

struct MyStream {
    int id;
    MyStream(){
        int cnt = 1;//改成非static,cnt不加static,每次构造都新建;加static,只初始化一次,一直存在,但输出的this是 s 对象的地址
        id = cnt++;
        cout << "构造对象, id=" << id << ", 地址:" << this << endl;
    }
    //拷贝构造
    MyStream(const MyStream& other){
        cout << "拷贝构造, this=" << this << endl;
    }
};
MyStream foo() {
    MyStream s;
    return s; 
}

int main() {
    auto p1 = foo();
    auto p2 = foo();
    cout << "p1地址=" << &p1 << endl;
    cout << "p2地址=" << &p2 << endl;
cout<<"_"<<endl;
    MyStream obj;
    cout << "obj地址=" << &obj << endl;
}
/*
root@VM-0-7-ubuntu:~/cpp_projects_2# g++ main.cpp -o main && ./main
构造对象, id=1, 地址:0x7ffcc5e569fc
构造对象, id=1, 地址:0x7ffcc5e56a00
p1地址=0x7ffcc5e569fc
p2地址=0x7ffcc5e56a00
_
构造对象, id=1, 地址:0x7ffcc5e56a04
obj地址=0x7ffcc5e56a04
root@VM-0-7-ubuntu:~/cpp_projects_2# 
*/
// 打印不出拷贝构造,NRVO生效,s直接在p1/p2的内存上构造


/*
去掉那个拷贝构造:

root@VM-0-7-ubuntu:~/cpp_projects_2# g++ main.cpp -o main && ./main
构造对象, id=1, 地址:0x7ffdb5bcb444
构造对象, id=1, 地址:0x7ffdb5bcb444
p1地址=0x7ffdb5bcb46c
p2地址=0x7ffdb5bcb470
_
构造对象, id=1, 地址:0x7ffdb5bcb474
obj地址=0x7ffdb5bcb474
root@VM-0-7-ubuntu:~/cpp_projects_2# 
*/
// 两次调用foo,栈内存复用,局部s地址重复;p1、p2在main栈上,地址不同


/*
带自定义拷贝构造版本:NRVO 生效,s直接在 p1、p2 内存构造,故s的 this 等于&p1、&p2。
移除自定义拷贝构造版本:NRVO 未生效,每次 foo 的局部s是独立栈变量,两次调用复用同一块栈空间,所以s地址相同,和 p1/p2 地址不一致
Q:我纳闷为啥同一份黛代码有无写拷贝这件事!居然会改变是否走拷贝这件事!
A:GCC 的 NRVO 有一条规则:如果用户显式定义了拷贝构造函数,GCC 依然可以做 NRVO;但如果隐式拷贝构造的情况下,在 -O0,GCC 选择不做 NRVO。
什么狗逼玩意啊我艹!!!写了拷贝不走拷贝,直接给NRVO优化了,不写拷贝反而走拷贝,没NRVO???????????傻逼垃圾C++
*/

第二次有了动摇的想法,真的不想学C++了
不是因为有多难,更不是啃不动,更不上没人教,而是一屁眼子玄学没个标准没个正确的东西

 

无意间发现:

VScode 快捷键:ctrl+shift+L是搜索同单词。

 

再看个例子:

查看代码
#include<iostream>
using namespace std;
struct MyStream : ostream {
    MyStream() : ostream(cout.rdbuf()) {}
    //  初始化列表,先构造基类子对象,即子类(派生类)里属于基类的部分,然后执行这个构造函数的 {} 即派生类构造体
    //第一个():构造函数形参列表,代表无参构造
    //第二个():调用基类ostream的构造函数,传入参数cout.rdbuf()
    
};
MyStream& foo() {// 去掉&就是值返回,会拷贝派生类对象,ostream禁止拷贝,为防止流对象被复制造成流缓冲区所有权混乱,标准库将ostream拷贝构造删除
    static MyStream s;// 如果没 rdbuf,这行就会合成默认构造,尝试调用基类ostream无参构造,派生类没手写构造,编译器会调用基类默认构造,该构造是 protected,报错
    // 且这玩意等程序结束才销毁
    //手写了带参构造,编译器不再生成默认构造

    return s; // 返回派生类对象,不是ostream
}
int main() {
    ostream &os_ref = foo(); // foo返回的派生对象,绑定到ostream&,如果是 static MyStream s 没static,则foo 返回临时对象,左值引用不能绑定临时,报错
    // 派生类左值赋值 / 绑定给基类引用,自动绑定到派生对象内的基类子对象,属于标准规定的向上转型,一律默认这么搞,用派生类类型引用:MyStream &os_ref = foo();才是绑定到子

    //ostream&是父类引用。父类引用可以绑定子类对象
    //不是发生类型转换、生成新对象。是引用绑定,同一个对象,只是用父类视角去看这块内存里的子对象
    
    
    os_ref << 123 <<endl;//123
    // 你的 MyStream 含有 ostream 基类子对象,os_ref 就是这个基类子对象的引用,只能看到基类接口,调用匹配ostream的函数。匹配全局 operator<<(ostream&,int)
}
/*
规则:MyStream 是继承,所以默认一定要包含一个 ostream 基类子对象,即你猫继承动物这个类,然后搞猫对象时候必然先弄块内存搞动物的东西,然后再往里加猫特有的,
但不是单独新建一个ostream对象,是在s这块内存里面,把s内部那块属于基类的子对象内存,用ostream(rdbuf())构造

流程:
进入 foo,static s 第一次初始化。要造 MyStream 对象,MyStream 包含一个 ostream 基类子对象。初始化列表ostream(cout.rdbuf()),调用 ostream 构造函数,创建这个内嵌的 ostream 基类子对象,至此搞好了基类的部分
然后执行派生类MyStream构造函数体,完整 MyStream 对象 s 构造完成,继续执行 foo 函数,return s,返回 s 的左值引用,

main 里 ostream & 引用绑定到派生对象 s;
调用 operator<< 输出 123;
程序退出,static 局部对象 s 才析构
*/



//且此文搜“傻逼东西(去掉 id 的 static)”
那个是:
auto &p1 = foo()
和
MyStream obj
都调用 MyStream() 无参构造
无继承,构造函数体内部写初始化

而这个继承ostream,在初始化列表构造基类子对象
如果不写无参构造,MyStream obj 会调用默认生的
    MyStream() : ostream() 
    {
    }
但这玩意被禁止,所以这里写了。

ostream带 streambuf 指针的构造函数是 public,所以你写std::ostream my_cout(std::cout.rdbuf());合法。
无参默认构造函数是 protected,所以ostream();、ostream A不能直接写。它有公有的带参构造,但没有公有的无参构造。

依旧是通过错的不考的来学,相当透彻!

ostream 属于库细节,不考但通过这个学单例、静态局部变量生命周期、左值右值(左值引用不能绑定纯右值)、引用绑定、对象生命周期、悬垂引用、继承基类引用隐式转换,是考点,节 ostream 代码来学这些考点。

 

讲下继承构造的事: 

struct MyStream : ostream {
    MyStream() : ostream(cout.rdbuf()) {}//这行如果注释会咋?    
};

如果去掉这个,甚至说只写MyStream(){},都不行,因为构造函数体执行之前,会先初始化基类,你没写基类的初始化列表(也即是冒号后的),就会尝试调用ostream(),它是 protected,报错。

关于初始化列表,这里是基类的,子类的成员也可以写,写上找找感觉,做点区分,更好的理解:

struct MyStream : ostream {
    int id;
    MyStream() : ostream(cout.rdbuf()), id(100) {}
};

冒号后,逗号分隔:先基类 ostream(cout.rdbuf()),再子类成员 id(100)。

 

之前的手写无参构造MyStream() : ostream(cout.rdbuf()) {}。

下面的Son() { cout << "【子对象构造】\n"; }也是构造函数。

构造函数是一个大范围,不是必须要有冒号:

  • 有冒号后面整体就叫初始化列表,里面可以写基类构造调用和成员初始化,像上面的那个MyStream() : ostream(cout.rdbuf()), id(100) {},

  • 没冒号也行,如果有继承这件事,会隐式执行基类默认构造,如果没继承就直接执行函数体内代码。

 

起初这些乱糟糟的,如今很透彻了!

关于【派生类构造时,先调用基类构造函数,再执行派生类构造函数体】:

当创建 Son 对象 s:

  1. 先创建嵌在 s 里面的 Father 那一部分(父子对象),跑 Father 的构造。

  2. 再跑 Son 大括号里面代码。

Father 类早就写好了,但每新建一个 Son,就要新建一份嵌在 Son 里面的 Father 部分,不是复用一个已经做好的 Father 全局对象:

查看代码
#include <iostream>
using namespace std;

struct Father {// 父类图纸:Father
    // cout<<"^"<<endl;//代码语法错误,类体里面不能直接放cout语句
    Father() {
        cout << "【父对象构造】\n";
    }
};


// 继承冒号!!只是图纸,只告诉编译器:Son里面带着Father那一份
// 这一行只是图纸,写在这里的时候,不会运行任何代码,不会调用Father()
struct Son : Father {//去掉 :Father 也等价,编译器会自动帮你补全
    Son() {
        cout << "【子对象构造】\n";
    }
};

int main() {
    cout<<"start"<<endl;
    
    Son s;// 就是这一行!!执行到这里,才正式开始造s这个对象
    /*
    造s的顺序:
    1. 启动 Son 的构造函数
    2. 先执行构造函数冒号后面 Father(),调用Father的构造函数
    3. 运行Father()里面cout,打印【父对象构造】,Father子部分建好
    4. Father构造跑完,才进入Son的大括号{}里面
    5. 运行Son里面cout,打印【子对象构造】
    6. Son构造跑完,对象s创建完毕
    */    
    
    cout<<"over"<<endl;
}

即,构造函数中的初始化手段:

  1. 初始化列表(冒号)

  2. 构造函数体内赋值

  3. 委托构造:

    查看代码
    #include <iostream>
    using namespace std;
    
    struct Test {
        int x;
        // 版本1:带参数构造
        Test(int v) : x(v) {
            cout << "Test(int) x=" << x << endl;
        }
        // 委托构造:在初始化列表调用本类另一个构造函数
        Test() : Test(66) { 
            cout << "委托构造执行大括号\n";
        }
    };
    
    int main(){
        Test t;
    }
    /*
    root@VM-0-7-ubuntu:~/cpp_projects_2# g++ main.cpp -o main && ./main
    Test(int) x=66
    委托构造执行大括号
    */

妈的从没想到,啃重载(操作数、<<)这些能扯到类、继承 这些,好头疼 ~~~~(>_<)~~~~,C++ 真的烦。

上面这些学懂了,继续说一个思路,也就是我之前钻牛角尖,一意孤行追问极致冷门不考的东西(为啥char*放类外)

成员函数调用时,.前面这个对象(左操作数)不会走隐式转换;普通全局函数的所有参数,都允许隐式转换。

a.b() 这种成员调用语法: a.operator<<(xxx) 假设a本身是继承ostream的,此时做重载<<时候,不会把 a 隐式转成 ostream。 就算 a 能转成 ostream,.调用也不做这个转换,

全局函数operator<<(os, val),等价普通函数调用,第一个参数 os 允许隐式转换

MyStream& foo();

foo() << "hi";

而 MyStream 不写任何成员,名字查找会沿着继承链向上找到 ostream 里的 operator<< 成员

foo()返回子类引用(不是 ostream 本身,但子类继承 ostream)

场景 1:

  • 如果 operator<<(const char*) 是 ostream 的成员函数,表达式翻译成:foo().operator<<("hi") .前面是子类。成员调用规则:不对.左边做隐式转换。编译器一看:foo()的类型不是 ostream,直接编译报错(哪怕子类能绑定到 ostream&,.语法也不会触发这个转换)这是错的!

  • 插一嘴题外话:

    查看代码
    //派生没有就去基类 A 里面找同名成员函数
    #include <iostream>
    using namespace std;
    struct A{
        void foo(){cout<<"A::foo\n";}
    };
    struct B : A{};
    int main(){
        B b;
        b.foo();//调用 b.foo(),找到 A::foo 后,隐式把派生类对象 b 的基类子对象(子对象里的基类)地址传给成员函数的形参this指针,这就是给 this 传参。
    }
    =====================================================================
    #include <iostream>
    using namespace std;
    struct Wrapper{
        operator int(){
            std::cout << "转换函数被调用\n";
            return 666;
        }
    };
    int main(){
        Wrapper w;
        int x = w;
        std::cout << x << std::endl;
    }
    // 这叫用户定义隐式转换,语法 operator T(),把本类对象隐式转成类型T
    
    // 定位 new(placement new):new (ptr) T;,不分配内存,只在已经备好的内存上调用构造函数,和转换函数operator T()完全两码事
    
    
    /*
    另外此文搜“后s销毁,得到野引用,而下面的值返回没这个”,那个static MyStream s存静态存储区,函数退出对象还活着,
    即那个例子里,如果不加 & 就是拷贝,这还好,如果加了 & 就是引用,就必须长久存在,不带 static 的 MyStream s 是栈对象,函数 return 后栈回收,对象直接销毁,引用绑定到已销毁对象,就是野引用。
    而这个 int 例子的临时是值拷贝,生一个临时666,然后拷贝给x,然后临时666没了
    */
    
    
    ===================================================================================
    #include <iostream>
    using namespace std;
    struct A { int v; };
    
    //版本1:转值 operator A()
    struct W1{
        operator A() { return A{1}; }
        /*
            回忆return 666
            int(666)是强制转换,int{666}是列表初始化
            所以非int其实直接写A就行
            这里写return A{1} 主要是个一些自定义类用的搞临时
        */
    };
    
    //版本2:转左值引用 operator A&()
    struct W2{
        A obj;
        operator A&() { return obj; }
        /*
            对比此文搜“后s销毁,得到”,那 foo 是个函数壳子,foo 里 s 是函数栈局部对象,离开函数作用域就销毁,所以 static
            这里 W2 的 obj 属于外层 W2 对象,W2对象活着obj就活着,有点像那里的 MyStream obj。
        */
    };
    
    //版本3:转右值引用 operator A&&()
    struct W3{
        A obj;
        operator A&&() { return move(obj); }// 转换函数返回obj的右值引用,obj归属W3实例,W3存活它就有效
        /*
        右值分为纯右值即临时和亡值
            纯右值:临时对象或者字面量
            亡值:对象本身还在,只是被标记可以被移动(原对象内存还在,资源被挪走,里面剩下的值不确定,是给移动构造函数干的,可以复用原有对象内存,省去新建临时对象的开销)
    
            w3的obj本体还在,只是被标记成可移动的亡值,资源还没被挪走,变成亡值只是类型层面标记,不会自动清空内容,只有移动构造/移动赋值执行时才会挪资源,W3进来兜一圈就是为了搞个obj这么大的地方,
            直接写A&& a3 = w3;等价
            a3 是绑定到 W3 内部 obj 的右值引用,那标记一下然后这块很有风险的玩意给a3,好坑人a3啊?不,只是拿到亡值引用,没有自动执行移动操作,风险来自使用者拿这个引用去调用移动相关函数。
            语法意义:允许函数接收右值引用参数时,能直接拾取已有对象的资源,不用复制。
            obj 的数据完好,a3 只是右值引用,不占有所有权,后续 a3 传进移动构造 / 移动赋值,就复用 obj 的资源
            
        */
    };
    
    int main(){
        W1 w1;
        A a1 = w1;
    
        W2 w2;
        A& a2 = w2;
    
        W3 w3;
        A&& a3 = move(w3);
    }

场景 2:

  • operator<<(const char*) 是全局重载 表达式翻译成:operator<<( foo(), "hi" ) 这是普通函数调用。函数参数可以隐式转换。 第一个实参foo()是子类引用,可以隐式绑定到形参ostream&。编译通过。

一句话浓缩: .成员调用:点左边不做隐式转换。 全局函数调用:所有实参都可以隐式转换。

成员的左边不能转,参数能转,然后全局没有左右,它只有参数,参数都可以转。

C++ 标准把字符串、数字这些operator<<写成全局,就是为了支持这种:左操作数是子类 / 可转成 ostream 的临时对象的写法。 而void*、endl操纵符这些,做成成员,是因为几乎不需要 “左操作数要隐式转换” 的场景。

再极简一句话:点号左边不隐转;函数参数可以隐转。所以字符串的 << 必须是全局重载,不然 子类对象<<"abc" 编译失败。

澄清一下!这里说的不对!也就是char*放类外根本不是这个原因:

foo是函数,返回值类型MyStream才继承ostream,派生类公有继承时名字查找会沿继承链找到基类ostream的成员operator<<,无需对点左操作数做隐式转换。 

foo()<<"hi"调用的是全局operator<<(ostream&, const char*),顺着继承链查到ostream的成员,代码合法,不需要把对象隐转成基类,不是成员operator<<。

查看代码
#include <iostream>
using namespace std;

struct MyStream : ostream{
    MyStream() : ostream(cout.rdbuf()) {}
};

MyStream& foo(){
    static MyStream s;
    return s;
}

int main(){
    foo() << "hi"<<endl;
}

这不是转换!所谓左操作数的不能转换指的是点号左边对象表达式不会做用户定义隐式转换,但派生类到基类的子对象访问属于继承链名字查找,不是隐式类型转换,此文搜“派生没有就去基类 A 里面找同名成员函数”也可清楚。

查看代码
#include <iostream>
using namespace std;

struct Wrapper{
    operator ostream&(){//这么写就是开了个口子,想转可以隐式转
        return cout;
    }
};

void test(ostream& os) {
    os << "hello\n";
}

int main(){
    Wrapper w;
    test(w); //函数实参,允许触发用户定义转换,调用operator ostream&()
    //w << "hi"; //点号左侧,不会调用这个转换函数去查找ostream的成员,Wrapper没有继承ostream,标准全局operator<<不能直接匹配Wrapper&,编译报错
}

点左是 Wrapper,有用户定义转换函数转ostream&;.查找成员只在 Wrapper 类里找,不会自动调用转换函数把 w 转 ostream 再去查 ostream 成员,编译报错。继承不是用户自定义转换,所以不能拿来演示这条规则,但这样可以:

查看代码
#include <iostream>
using namespace std;
struct Wrapper{
    operator ostream&(){
        std::cout << "转换函数被调用\n";
        return cout;
    }
};
int main(){
    Wrapper w;
    ostream& r = w;//ostream& r:定义一个名为r的ostream类型引用,用w初始化ostream引用r,触发operator ostream&()用户转换函数
    /*
    Q:r是类?
    A:
        r不是对象,是ostream的引用,绑定到转换函数返回的cout
        int &ra = a,ra是a的别名,类型int
        ostream &r = w,r是cout的别名,类型ostream,不是引用变成对象,引用指代的东西才是对象
        int的实例叫int变量;ostream类的实例叫ostream对象,仅此叫法差别
        
        Wrapper对象本身依旧是Wrapper,只是能通过这个转换函数,隐式得到一个ostream&,开个口子随时可以用
    */    
        
    r << "hi";
}

 

另外上面讲了【派生类左值向上绑定基类左值引用】,但 右值ostream(ostringstream)是不考的,懒得看。

 

又扯到任何人都不可能知道的 静态类型(不是static),但又通过不考的,学到了更深刻的考的东西,从文搜“注意编译期不分配也不划定也不规划内存”,无穷无尽的看似毫无关联的真的都是本质相通的,之前其实acm离散化、零拷贝(云服务端类比)这里的静态类型、静态存储区有点像懒加载懒汉饿汉!!!

因为,类型 / 大小编译期确定 ≠ 内存编译 / 加载期分配

静态存储对象放在静态存储区,指的是全局和static那些,他们跟new堆、栈、局部普通没啥大区别,唯一就是【内存加载时分配、生命周期到程序结束】,不是 “只有静态存储区的大小和类型是编译期算的”!!!

局部变量的类型、占用字节大小编译期也会算好写进程序,但内存空间不是编译期分配,运行到代码行才在栈上开辟。静态存储对象特殊点:内存在程序加载阶段就分配好。

静态局部对象的初始化就是懒初始化(第一次执行到这行才构造,加载时只是预留内存,不跑构造函数),和饿汉、懒汉单例里那套思路能对上。

  1. 编译期算类型、大小,写进可执行文件;不是分配内存。

  2. static 静态存储对象:程序加载时,直接在静态存储区分配内存,程序全程存在。

  3. 局部栈对象:类型、大小编译期算好写进程序;内存是运行到函数时,临时在栈上分配,函数退出就回收。

  4. 堆对象:类型、大小编译期算好;new运行时才去堆里申请内存。

  5. 静态全局对象:程序启动、进入main之前就完成初始化,不是懒的。 

饿汉:全局作用域静态对象;懒汉:函数内部static局部对象。

静态存储区内存加载时就预留;静态局部对象只是构造函数懒执行,内存本身早就划好了。

栈:运行进入函数时挪栈指针;堆:执行new时调用函数向操作系统申请。

全局static、函数内static,内存都是程序加载时就分配在静态存储区;差别只在构造时机:全局static在main前构造,函数内static等到第一次执行到那一行才构造。

以上是静态存储区,而静态类型其实完全没意义,因为他指的是编译器确定这件事,堆栈局部全他妈是静态类型,那冒着这么大的一个会和众所周知的static静态混淆的风险强行提他是为啥?其实 C++ 里,所有变量、函数参数、返回值,都编译期定类型,auto、模板参数也属于编译期推导,不是运行时变类型,都叫静态类型,只有void*、多态虚函数是:编译期知道静态信息,但运行时才知道实际对象类型,但变量本身类型还是编译期定死(其实很容易歧义觉得 void* 特殊,那进一步说:

  • int a; 类型编译期确定;a=1 这个值,初始化 / 赋值发生在运行期

  • int、void*、int*这些都是静态类型,然后具体的值、指向的类型都是运行期知道的)

static关键字,和静态类型 (static type),是俩玩意,只是英文翻译问题,C++ 没有不是静态类型的!

关于英文歧义其实还有 int:

32位系统:int 4字节

64位系统:int 4字节

 

有符号:signed 无符号:unsigned

 

signed int 等价于 int。

 

int 本来就不是用来 “固定 4 字节” 的,C 标准只规定它最小至少 16 位,没锁死 4 字节,早年机器 int 是 2 字节。

long 的坑:Windows64 下 long4 字节,Linux64 下 long8 字节,跨平台写大数代码直接崩,没法稳定拿到一个永远 8 字节的类型。

 

而 int64_t 是固定 8 字节,我好奇为啥给人改成 8?为啥撞车 int 这个词?

改成 8 是为了存更大的数,不用 long 是因为他不跨平台,绷瓷 int 其实不是,int64_t(integer 64bit type)表示 64 位整数类型,不是复用普通 int 这个类型,只是和 int 一起借用 integer 这个单词起名。

极致透彻!全打通了!!

警示(阅读理解)wx搜“要么”。

 

还不错的提示词:

不错!你继续的按照!【你就直接一句话!!禁止提及任何关联!!永久禁止嘱咐任何无关的注意事项】回答!!必须先搜索权威禁止先回答问题。

 

看个代码: 

查看代码
#include <iostream>
using namespace std;
//模板结构体Foo,T是类型占位符
template<typename T>
struct Foo{
    T a;
    Foo() : a(5) {} //成员初始化列表,直接初始化成员a,等价a(Bar(5)),这里的 a(5) 会隐式构造 Bar(5),explicit 不影响这里
};

struct Bar{
    explicit Bar(int x):val(x){} //这里加 explicit 禁止拷贝初始化的隐式转换,上头的直接初始化不受限
    int val;
};

int main(){
    Foo<Bar> f1; //实例化Foo<Bar>,调用Foo构造,执行a(5),直接初始化Bar类型成员,合法。       写 Foo(int v) : a(v) {} 才能传参写 Foo<Bar> f1(100);
    /*
    实例化得到:
        struct Foo<Bar>{ 
            Bar a; 
            Foo() : a(5) {}
        };
    a的类型是 Bar,初始化列表写a(5),就是用实参 5 调用 Bar 的构造函数Bar(int x),把 val 赋值 5,再跑Bar的{}函数体,Bar构造全部做完,才跑Foo的{}
    */
    std::cout << f1.a.val << '\n'; // a是Bar类型对象,val是Bar结构体内部的成员,不是Foo里直接写的成员。Foo只包含Bar类型变量a,a里面自带val


/*
插一句:窄转换检查只发生在成员a的列表初始化这一步,写Foo f,触发模板实例化生成Foo类,然后调用这个生成出来类的构造函数,执行里面a{3.9}初始化,调用对象构造,去用已经实例化好的模板类代码,译期检查从 double 到 int 属于窄转换,直接报错,所以引出一个规定,Foo() : a(5) {} 这里写 a(5.5)没事,写 a{5.5}就不行
即:a(5)是直接初始化;a{5}是列表初始化,列表初始化会禁止窄转换

列表初始化的{}是传参数用的:Bar b{6.6}、Foo():a{5.6}{}里a{5.6}
可以写成explicit Bar(int x):val(x),val(x)换成val{x},val{x}属于成员的列表初始化,会检查窄转换
不能写Bar{int x},构造函数形参列表只能用圆括号()
逻辑链条是实参给形参传递东西,形参是 int 永远不会出错,a{5.6}里的是实参准备被 Bar 的,不是只有你 main 里写的调用才是实参
豆包说必须匹配,可是狗逼表述垃圾,他妈的
如果:
    Foo() : a(5.5) {}
    explicit Bar(int x):val{5}{}没任何问题
我一直以为这种传递和接收都要是符合,结果直接只看单一!人家(5.5)直接截断为5,所以屁事没有

*/

    // Bar a = 5; //拷贝初始化。尝试隐式转换5→Bar,explicit阻止,编译报错
    Bar b(6);  //直接初始化。直接调用Bar(int),explicit不拦,合法
    std::cout << b.val << '\n';

    Bar c{7};  //直接初始化(列表初始化)。直接调用Bar(int),explicit不拦,合法
    

    Foo<int>haha;
    cout<<haha.a<<endl;
    // Foo的成员叫a,类型是Bar对象;Bar内部成员叫val,是int。a是对象,val是这个对象里面的int变量
}

Bar(6)、Bar{7}是类类型直接初始化,调用构造函数;int(1)是基础类型的函数风格转换语法,不调用构造函数。

这个代码也证明了a=5是确定为int,而Bar非int,只能写Foo() : a(5) {},这就回答了我好奇的问题,当a是int,为啥要有int(5)这个东西,但他妈的int(5)明显是类型转换,所以这个重复了,只能用隐含的T来表示,Foo<int>haha;等价实例化:

struct Foo<int>{
    int a;
    Foo() : a(5) {}
};
// a(5) 对于int,就是直接初始化,不是调用构造函数,int没有构造函数

 

 

看几个代码:

查看代码
#include <iostream>
int main(){
    const char s[] = "hi";
    std::cout << s << '\n';// hi
    /*
    cout << s 属于运算符表达式,会同时做两类查找:
        成员查找:在std::ostream类里找到成员operator<<(const void*)
        ADL 查找:在std命名空间找到自由函数operator<<(std::ostream&, const char*)
    然后重载决议比较:const char*到const char*(自由函数)优于const char*转const void*(成员函数)
    */

    std::cout.operator<<(s) << '\n';// 0x7fff15ec9f05
    // 写cout.operator<<(s)是显式调用成员函数,不会触发 ADL,只查找类内成员,此时s退化成const char*,匹配成员operator<<(const void*)
    //如果库把const char*放成员,那不就匹配cosnt char*了吗?不就输出hi了吗
}

再看:

查看代码
#include <iostream>
using namespace std;

struct MyOstream : ostream {
    MyOstream() : ostream(nullptr) {}
};

MyOstream f() { return {}; }

int main() {
    // 如果 operator<<(const char*) 是 ostream 的成员:
    // f() << "hi"; 等价 f().operator<<("hi");
    // f()是MyOstream,.调用成员,不能把MyOstream隐式转ostream&,编译报错

    // 现在标准是全局operator<<(ostream&, const char*):
    f() << "hi"; //等价 operator<<(f(), "hi");
    // 第一个参数允许隐式转换 MyOstream → ostream&,编译通过
    
    /*
        后来得知这个不叫隐式转,就是普通去基类?
        不对!
        成员调用f().operator<<("hi")是直接在派生类对象里查找基类子对象,不是参数隐式转换
        全局版本operator<<(f(),"hi")才是派生类到基类引用的隐式转换。
        
        Q:都是子类到基类!咋一个叫隐士转!一个不叫????
        A:成员调用是直接访问对象里自带的基类子对象;全局函数传参,是将派生类对象隐式转换成基类引用,这是两种不同场景
    */
}

 

豆包说过的也就是网上垃圾资料,误以为char*放成员里会歧义匹配,其实不会!

char*做成成员不会被const void*优先匹配。重载决议里,const char*到const char*是精确匹配;const char*转const void*是转换匹配。精确匹配优先级更高。如果类内有operator<<(const char*)成员,调用cout.operator<<("hi")就直接打印字符串,不会走到const void*。

 

 

傻逼豆包总提及早就被utf8取代的傻逼知识点:

早年宽字符 wchar_t:每个字符占固定字节。

Linux wchar_t 是 4 字节,例子:A,存成 0x00000041;中,存成 0x00004E2D。

  • 好处:找第 N 个字符直接下标访问,不用扫前面字节。
  • 坏处:英文字母也要占 4 字节,浪费空间;存文件 / 发网络要转码。

UTF-8 变长: A:1 字节 0x41;中:3 字节 0xE4 0xB8 0xAD。

  • 好处:英文和 ASCII 一模一样,不用转码直接写文件、网络发。
  • 坏处:找第 N 个字符,必须从头挨个扫描,不能直接下标(只在随机访问第N个字符时慢,顺序遍历(读文件、网络流)速度差距很小,绝大多数场景都是顺序读)

00 00 00 41是十六进制,每个十六进制数字代表 4bit,两个十六进制数字组成 1 字节。 

。

关于 cout 说个东西 结束!

继续看编程指北:

查看代码
#include <iostream>

void foo(int x) {
    std::cout << "foo() called with an int: " << x << "哈好"<<std::endl;
}

void foo(char* x) {
    std::cout << "foo() called with a char*: " << x <<"哈哈"<<std::endl;
}

int main() {
    // foo(NULL); // 编译错误:因为 NULL 会被解析为整数 0L,而候选int和char*都是可以隐式转的,导致二义性,报错为 overloaded:重载;ambiguous:有歧义的;candidate:候选函数
    foo(nullptr); // 无歧义:调用 void foo(char* x),nullptr类型是std::nullptr_t,该类型可隐式转换到任意空指针类型(含char*、void*),重载决议优先匹配目标类型更精确的char*,但传char*空指针给operator<<(ostream&,const char*)属于未定义行为,访问空地址,程序崩溃,后面文本不再打印
}
/*
root@VM-0-7-ubuntu:~/cpp_projects_2# g++ main.cpp -o main && ./main
foo() called with a char*: root@VM-0-7-ubuntu:~/cpp_projects_2# 
*/

此处代码追问豆包完善了下!

向operator<<传入空const char*属于未定义行为。

 

查看代码
#include <iostream>
#include <type_traits>

template <typename T>
void bar(T x) {
    if (std::is_same<T, std::nullptr_t>::value) {// std::is_same<A,B>::value,判断A、B是不是同一类型,返回bool编译期常量
        std::cout << "bar() called with nullptr" << std::endl;
    } else {
        std::cout << "bar() called with a non-nullptr value" << std::endl;
    }
}

int main() {
    bar(NULL); // 因为 NULL 被解析为整数 0,NULL 的定义依赖平台,部分平台定义为0L即 long,部分是 0 即 int
    bar(nullptr); 
}
/*
root@VM-0-7-ubuntu:~/cpp_projects_2# g++ main.cpp -o main && ./main
bar() called with a non-nullptr value
bar() called with nullptr
root@VM-0-7-ubuntu:~/cpp_projects_2# 
*/

总之,C++11 引入了 nullptr 作为一个更安全、更明确的空指针表示,可以避免与整数 0(即 NULL)相关的一些问题。

在 C++11 及以后的代码中,建议使用 nullptr 代替 NULL 表示空指针。

 

妈的博客园之前有 10w 字限制后来没了,如今又有了 20w 字数限制!!估计我近期有加载那个超长文字所以触发的。      

 

关于 C++ 类型萃取(type_traits)详解【C++ 类型萃取 type_traits】:

 

 

 

posted @ 2026-09-26 01:31  GerJCS  阅读(4)  评论(0)    收藏  举报