oop
1.0输入输出流
标准输入输出
c++
iostream cin,cout
文件输入输出
1 | <fstream> |
格式化输入输出
1 | <iomanip> |
java
标准输入输出
System.out.println(“Hello World”);
Scanner scanner = new Scanner(System.in);
文件输入输出
1 | import java.io.File; |
格式化输入输出
String.format()
或 System.out.printf()
1.1 变量和动态内存分配
cpp
全局变量:可在不同cpp文件间共享,用extern应用别的文件变量
静态全局变量:static 修饰,只在本文件共享
静态局部变量:static 修饰,只本函数内共享,存储在全局区,第一次执行到定义时初始化,并在函数调用结束后仍然保留值,在后续调用中继续使用该值。
局部变量:栈中
动态分配变量 (allocated variable):使用动态内存分配( new )创建,存储在堆区,需要程序员手动释放
new执行类和对象的构造函数,malloc,delete删除指针指向的对象的值,而不是删除指针
1 | T* p = new T; // 默认构造,内置类型不初始化;类类型走默认构造函数 |
1 |
|
不会分配内存,只调用构造函数。
不需要 / 不能 与 delete 搭配;析构后由调用方负责释放托管内存。
注意
内存泄露,多次delete,悬空指针
如果两个指针指向同一块堆内存,其中一个被 delete ,另一个再访问就成为悬空指针,若需要让多个指针共享所有权,应使用智能指针(见第6节)。
java
Java 中没有独立于类之外的全局变量概念。需要跨作用域共享的数据通常通过类的 静态变量 来实现(相当于类级别的全局变量)
成员变量:包括实例变量和静态变量。实例变量属于对象,随对象存储在堆上;静态变量用 static定义,属于类本身,在整个应用运行期间存在(类似 C++ 的全局变量),对所有该类的对象共享,存储在方法区或堆的特殊区域。
局部变量:定义在方法或块内部,作用域仅限其中,生命周期随调用结束而结束。Java 的局部变量存储在栈上(对于原始类型)或作为引用存在栈上(引用类型的对象本身仍在堆上)。
动态分配:一切靠new.
1 | Integer p = new Integer(123); |
在cpp会导致原来那部分内存泄露,java不会,Integer(123) 一旦没有任何引用指向,就会被垃圾回收(具体回收时间不定)Java也不存在 C++ 中野指针、悬空指针的问题——如果引用超出作用域或被赋为 null ,之后便无法通过它访问对象。
在 Java 中,多个引用指向同一对象很常见,赋值只是复制引用,GC 会跟踪对象的引用计数或可达性,当没有引用时再释放对象,因此无须手动管理多引用场景。
1.2 引用
cpp
引用后就被绑定,必须连接到合法内存上的对象不能储存NULL
1 | int *f(int *x) { // 接受指针,返回指针 |
需要注意:返回局部变量的引用会产生悬空引用(对象已销毁),这一点和返回指针类似。
引用主要用于函数参数和返回值传递,提供类似指针的效率又避免了空指针错误,在需要“不改变原对象”或“直接修改原对象”时结合 const 使用非常方便。
java
没有指针,所有变量都看作引用,开发者无法操作内存。给引用重新赋值就不会影响原对象。引用 相当于传入一个指向对象地址值的东西。
1 | void addOne(Integer x) { |
Java 则始终按值传递,基本类型按值拷贝,对象类型按引用拷贝。如果想避免函数修改传入对象,可以在函数中创建对象副本或在设计上使用不可变对象。
反之如果想在函数中“返回”一个修改后的原始数据,Java 要么通过返回值(返回新数据)要么传入可变对象来修改其字段。
1.3 const 类型
cpp
初始赋值后不可更改值,编译器常量
const和指针有多重组合形式
1 |
|
直接将字符串字面量赋给 char* 在 C++ 中是非标准行为,要么赋给指针常量,要么赋给数组,可以改变值char s2[] = "Hello"; // 或者用数组拷贝字面量,允许修改 s2 内容
const 成员函数
在成员函数签名后加 const ,表示该成员函数不会修改所属对象的任何数据成员。
只有当对象本身是非 const 时,才能调用非 const 的成员函数,无则调用const版本;而const 对象只能调用 const 成员函数 。如果类中同时定义了 const 与非 const 的同名成员函数,它们构成重载关系
1 | class A { |
const参数和返回值
函数形参可以声明为 const,表示在函数内部不修改该参数(如果是按引用或指针传递,则不修改原始对象)。函数返回值也可声明为 const,表示返回后不能修改返回的对象。例如返回 const 引用,可防止调用者通过引用修改内部数据。
java
变量
Java 没有 const 关键字用于变量或函数,但提供了功能近似的 final 关键字: 用 final 声明的变量初始化后不可再次赋值,相当于只读变量。
也可以把对象设计为不可变类,如String
与 C++ const 不同的是,Java 的 final 变量可以是类的成员,也可以是方法内的局部变量。对于引用类型, final 仅保证引用本身不可改变指向,但引用指向的对象内容若是可变的仍可修改(除非那些内容也有自己的限制)。就是相当于只有常量指针,没有指针常量。
final方法和类
将方法声明为 final ,则子类不能重写该方法。这在语义上与 C++ 中将方法声明为virtual 后又标记 override final (C++11 引入)相似,都是为了禁止进一步覆写。
将类声明为final 则表示该类不能被继承,类似于 C++11 中类声明后加 final 的效果。
没有不可变成员函数
1 | public static final double PI =3.14159; |
1.4 拷贝构造和赋值
cpp
如果将一个对象直接赋值给另一个新创建的对象,会调用拷贝构造函数;如果是先创建对象再赋值,则调用赋值运算符。拷贝构造函数一般定义形参为 const ClassName& ,这样才能接受 const 或非 const 对象。缺省情况下,编译器提供的拷贝构造和赋值运算符执行浅拷贝(逐成员拷贝) 。对于包含指针成员的类,这可能导致多个对象共享同一块内存(危险)。需要根据情况实现深拷贝(分配新内存复制内容) 。
1 | struct Person { |
由于 p1 和 p2 拷贝后内部指针相同,销毁时会发生双重释放错误。因此,若类有资源需要深拷贝,必须重载拷贝构造和赋值运算符,以执行资源的复制和独立管理。C++11 起也可通过删除拷贝构造/赋值( =delete )
java
引用
在 Java 中,对象变量存储的是引用,赋值对象变量只会拷贝引用值,而不会复制对象本身(相当于浅拷贝地址)。因此不存在 C++ 的拷贝构造函数语义: Object obj2 = obj1; 在 Java 中只是让 obj2 引用与 obj1 相同的对象,并不创建新对象。
复制对象
1)想要“两个独立对象”,必须手动 new 并根据原对象内容赋值。
2)提供对象的克隆方法,实现Cloneable 接口并覆盖 clone()
3)提供拷贝构造函数或者静态工厂来自行实现深拷贝
操作符重载
由于没有拷贝构造和赋值重载,Java 也不存在赋值操作符重载的问题。
比较
用== 比较对象比较的是引用是否相同,类似C++比较指针地址;
要比较内容需要调用 .equals() 方法(类似C++中自定义的 operator== 重载,但Java中很多标准类已提供 .equals 实现)。
1.5 extern
Java 没有C++ extern 等概念,因为静态变量天然在整个应用类加载后可访问(权限允许的情况下)。
2 类
类是面向对象的基础,它将属性(成员变量)和操作(成员函数)封装在一起 。
任何实体都可被抽象为类的对象,程序就是对象之间通过方法调用和消息传递协作完成的
一个类可以被多次实例化生成多个对象,每个对象都有自己的一份实例成员数据。
cpp
C++ 中使用关键字 class 定义类,默认成员访问权限为 private ;也可使用 struct 定义,默认权限为public ,但在 C++ 中 struct 除了默认权限外与 class 无本质区别
类成员的定义在实现文件中需要使用作用域运算符 ClassName:: 来指明所属类 。
java
Java 的面向对象模型与 C++ 相似,也是用 class 定义类,将字段和方法封装.
主要区别在于源文件结构:Java 通常一个 .java 源文件只含有一个公共类(类名须与文件名匹配),类的定义和实现不分离,在同一个文件内编写。
Java 中用点号 . 访问类的成员(无论静态或实例),没有 C++ 中
的 :: 运算符
Java 有包 (package) 的概念,用类似目录的命名组织类名,点号也用于包名和类名之间。例如 java.util.List 表示 util 包下的 List 类。Java 使用 import 声明来引用其他包的类,而不是通过头文件包含。这样,Java 避免了C++头文件重复包含的问题,编译依赖管理更简单。 Java 的 import 只是告诉编译器“我要用哪个类”,而 C++ 的 #include 是把别人的源代码(可能包含实现、宏、模板)一股脑拷进来。
因而 Java 不会遇到头文件重复、编译单元暴涨、宏侧效应等问题,依赖图天然更小,增量编译、冲突排查和构建系统实现都更简单。
Java “符号引用”让编译器只关心 接口,不关心 实现文本
编译 Bar.java 时,若用到 pkg.Foo:编译器在类路径或模块路径找 Foo.class(已编)或 Foo.java(未编)。
只读取 Foo 的 符号表(字段、方法签名)即可继续;Foo 的方法体此刻不必看。因此 接口变了才会影响下游。方法体内部修改→下游类 无需重编译;链接在运行期由类加载器处理。
C++ 由于把函数实现也写进头文件 (inline/template) 的普遍做法,只要实现改动,包含它的所有 TU 都要重编译。
2.1 构造函数析构函数
cpp
构造函数
在创建对象时自动执行,用于初始化对象 。可以定义多个构造函数(构成重载)以应对不同初始化需求 。如果没有定义构造函数,编译器会提供一个默认构造函数。构造函数可以带参数,创建对象时需要传入。默认构造函数指无参数或参数都有默认值的构造函数
初始化列表
1 | Sample(int x) : id(x) { // 用初始化列表将 id 初始化为 x |
初始化列表的执行顺序按成员在类中声明的顺序,而非列表中出现的顺序。使用初始化列表还能提高性能(直接构造,而不是先默认构造再赋值)
拷贝构造函数
1 | ClassName(const ClassName &other) |
移动构造函数
1 | ClassName(ClassName &&other) |
用于获取临时对象的所有权,避免不必要的拷贝
重载
定义无参构造,定义带参构造。
注意若定义了任何构造函数,编译器不再自动提供默认构造,若仍需无参构造必须显式定义。
还有const构造函数。
析构函数
类名前加 ~ ,在对象生命周期结束时自动调用(离开作用域、delete对象时) 。析构函数无参数、无返回值,每个类最多一个析构函数。
会默认生成
注意基类的析构
函数应声明为 virtual (虚函数)以保证通过基类指针删除派生类对象时调用正确的析构
java
用与类同名的函数作为构造方法,在new ClassName()的时候调用。可重载,不支持默认参数。
和cpp最大的区别是:Java 不允许直接调用构造方法创建临时对象(C++ 可以直接构造临时对象),必须使用 new 。
Java 没有语言层面的拷贝构造函数。如果需要复制对象,一般自行编写一个构造方法接收同类对象并复制其字段。
Java 没有显式析构函数。
C++ 把“释放资源”的逻辑写进析构函数,作用域一结束就自动执行;Java 只保证帮你回收内存,对文件、网络等必须用 try-with-resources 或手动 close(),否则就会泄漏。机制不同,但都是为了同一个目标——让资源生命周期在代码中被可靠地管理。
2.2 static类型成员
类成员分为实例成员和静态成员。
静态成员属于类本身,在所有对象间共享。
cpp
静态成员变量
在类内用 static 声明静态成员变量,但不分配存储。必须在类外定义并初始化一次
1 | class A { |
静态成员变量在程序开始时分配,结束时释放,生命周期独立于任何对象。若未显式初始化,静态成员会被默认初始化为零(对内置类型)。
静态成员函数
用 static 声明的成员函数,不作用于特定对象上,没有隐含的 this 指针 。因此静态函数内只能访问静态成员,不能直接访问实例变量或实例函数
调用静态成员函数无需对象实例,可直接用 ClassName::FunctionName() 语法调用,或通过类名作用域限定调用。
在函数内用static修饰的局部变量
则该变量在函数调用间保持值(静态存储区),只在首次调用时初始化 。这与类静态成员不同的概念,但利用相同关键字。前文1.1节已讨论过静态局部变量的行为。
java
静态成员变量
和cpp一样,区别是定义和初始化在类内部就实现,不需要在类外另行定义。
访问静态变量通常直接通过类名,例如 ClassName.staticVar 。也可以通过对象引用访问(如obj.staticVar ),但会被编译器转为类名访问形式。
1 | class A { |
静态方法
用 static 修饰的方法,不依赖对象实例。调用时直接用类名即可,如Math.max(a,b) 。也没有this无法访问非静态成员。只能使用传入参数或静态变量,不能访问实例成员除非被当做参数传入。静态方法常用来实现工具类方法(如 java.lang.Math 的静态方法。
静态块
Java 类可以包含静态初始化块,在类加载时执行一次,用于复杂静态变量初始化。这在C++中通常通过全局对象或函数静态对象实现。
1 | import java.util.HashMap; |
C++ 没有直接对应的 static { … } 语法。
我们通过一个私有的静态函数 getCodeMap() 来封装初始化。
函数内的 static const CodeMap instance 是核心。它只会在 getCodeMap() 第一次被调用时才进行初始化。这是一种延迟初始化(Lazy Initialization)。
1 |
|
缺少
但C++还多了静态局部和自由静态函数的概念(后者是命名空间级的函数,与类无关),Java 则没有全局函数,一切函数不是实例方法就是静态方法属于某类。
2.3 内联函数
cpp
关键字 inline 可以建议编译器在调用处展开函数体,以减少函数调用开销 。内联是在编译阶段完成的优化,相当于宏替换但更安全(有类型检查)。内联函数的定义通常需要在每个使用的地方可见(因此常定义在头文件)。
类内部定义的成员函数默认就被视为内联函数(编译器可自行决定是否内联)
本质是空间换时间
现代编译器会自动进行内联优化,无论函数是否标记 inline,只要符合优化策略。相反,即使标记 inline,如果函数体过于复杂,编译器也可能选择不内联。另外,递归函数通常不会内联(或只内联部分层次),因为展开会无限增大代码。过度使用内联会导致可执行文件增大。C++17 起内联关键字在 ODR(One Definition Rule)上也有特殊作用(如内联变量),但本节聚焦函数。
java
Java 没有显式的 inline 关键字。早期 JVM 对于频繁调用的小方法,会在 JIT(Just-In-Time)编译阶段自动将其内联优化。这完全由 JVM 来决定。不过,Java 有些替代手段,例如将方法定义为 private 或 static 且简单,JIT优化时更倾向于内联这些可预测的方法。此外,JVM 会基于性能监测(如某方法调用次数超过一定阈值)进行内联优化,这比静态编译器更动态智能。
需要说明的是,Java 字节码层面每次方法调用都是一个指令,例如 invokevirtual 或 invokestatic ,而是否内联由JIT在运行时动态优化决定,开发者不需也不能干预。所以Java开发中不会显式讨论某方法是否内联,但JIT优化技术确保性能接近C++的优化水平。
对比
C++ 程序员通过 inline 控制编译期优化,而 Java 依赖JVM在运行期的优化。C++ 内联可减少函数调用开
销但会增加编译时间和可能代码膨胀;Java 的动态内联由JVM智慧管理,既保证性能又减少人为失误。现代C+
+与Java在优化上逐渐趋同——都更依赖优化器而非手工提示。不过,在高性能场景下,C++ 开发者仍有比
Java 更直接的控制权。
2.4 继承
继承 vs 组合: 继承( class Derived : public Base )意味着 Derived 是一种特殊的 Base,继承Base 的属性和行为 。组合则是在类中定义成员对象,将其他类作为自己的组成部分。两者都是复用代码的手段:继承易于表现“是一个”关系,组合表现“有一个”关系。
cpp
C++ 支持单继承和多重继承(一个派生类可有多个直接基类)
继承的成员可见性
派生类继承了基类的所有成员(包括私有成员,只是私有成员不能直接访问)。基类 public 成员在派生类中保持 public , protected 成员在派生类中仍是 protected ,private 成员则始终无法直接访问(只能通过基类的公有/受保护方法访问)
C++ 有三种: public 继承(常用,保持基类接口), protected 继承(基类 public 成员变成protected), private 继承(基类 public/protected 成员均变成 private)。默认继承方式: class默认私有继承, struct 默认公有继承。
菱形继承
当一个类同时继承的两个父类,而这两个父类又继承自同一个“祖父”类时,继承关系图看起来就像一个菱形
为了解决这个问题,引入了虚继承(Virtual Inheritance)。通过在继承时使用 virtual 关键字,可以告诉编译器,对于共同的基类(Machine),只保留一个实例
1 |
|
Machine 的构造函数现在只被调用了一次!这是由最派生的类 Copier 的构造函数负责调用的,确保了 Machine 子对象只被初始化一次。
假如没有虚继承,那么Machine 的构造函数被调用了两次。这证明 myCopier 对象内部包含了两个独立的 Machine 子对象。
构造和析构的顺序
创建派生类对象时,会先调用基类构造函数(若有多级继承,按继承链从上到下调用),然后按声明顺序构造派生类的成员对象,最后调用派生类自己的构造函数体
析构顺序与构造相反:先执行派生类析构函数体,然后依次调用成员对象析构,最后调用基类析构 。这保证基类部分始终在派生部分之前正确建立和销毁。
如果派生类没有定义构造函数,将隐式调用基类的默认构造函数 。
如果派生类定义了构造函数但未显式调用基类构造,编译器会尝试调用基类的默认构造函数 。因此,基类最好提供无参构造或派生类需调用特定构造,以避免编译错误。
当继承和组合两种情况同时出现时,先构造基类,再构造派生类中组合的其他类,再构造派生类。就算组合的类在初始化列表或者构造函数中没有调用构造函数,C++ 编译器也会自动调用这个类默认的构造函数
派生类显式调用基类构造
1 | class A { |
假设以上构造函数输出调试信息,则执行 B b(100) 时的输出顺序可能是:
1 | A(): 0 // 基类 A 的构造(B未显式调用基类带参构造,默认调用无参) |
隐式调用成功 (基类有无参构造函数)
1 |
|
输出:
1 | 基类 Vehicle 的【无参】构造函数被调用。 |
编译失败 (基类无默认构造函数)
1 |
|
1 | error: no matching function for call to 'Vehicle::Vehicle()' |
编译器不知道如何构造 Vehicle 部分,因为它找不到一个不需要参数的 Vehicle 构造函数。它不会猜测应该给 Vehicle(std::string n) 传递什么字符串。
派生类构造函数必须承担起责任,显式地告诉编译器应该调用基类的哪一个构造函数,并提供必要的参数。
1 |
|
方法覆盖 (Overriding)
派生类可以重定义继承自基类的成员函数。
C++中,如果基类方法非虚函数,这不是严格的“覆盖”,而是隐藏.如果派生类定义了与基类同名但不同参数的函数,基类的同名函数会被隐藏——要使用基类版本需加类名限定或 using Base::functionName 引入
若想实现多态,基类函数必须声明为 virtual ,详见后面。
访问控制
C++ 使用 public 、 protected 、 private 来控制成员访问权限 。
public成员任何地方可访问;protected成员对子类和友元可见;private成员仅本类和友元可见
friend
在类中声明一个全局函数或者其他类的成员函数为 friend,可以使这些函数拥有访问类内 private 和 protected 类型的变量和函数的权限.友元函数也可以是一个类,这种情况下被称为是友元类,整个类和所有的成员都是友元.
1 | class A { |
继承方式也会影响这些权限在派生类中的表现,如前述。
java
extends
class B extends A { … }
所有非 final类都可被继承。
final
ava 用 final 关键字防止继承或覆盖。标记为 final 的类不能有子类(如java.lang.String ),final 方法不能被子类覆盖。这和C++中 final (C++11) 的用法类似。不过C++final 出现较晚,Java 从一开始就有。
interfaces
为实现多继承的某些好处,Java 提供接口。接口可以理解为只有纯抽象方法(Java8之前)的集合,相当于C++中的抽象类且所有方法纯虚。
当一个类 implements 多个接口时,就相当于获得了多继承的效果
比如,抽象类 - 定义“是什么”
1 | // Animal.java |
Animal 类不能被实例化 (new Animal(“…”) 会编译错误)。
接口 - 定义“能做什么”
1 | // Flyable.java |
接口没有实例字段和构造方法,因为它不代表一个具体的“物体”,只代表一种“能力”。
类:组合
1 | // Dog.java 一个简单的子类 |
成员继承
Java 只支持单一继承:一个类只能直接继承一个父类(除 Object 无父类)
不支持多重继承是为了避免菱形继承的模糊和复杂性
Java 引入 接口 (interface)机制,实现类似多继承的效果(详见下文)
Java 没有像 C++ 那样的继承权限级别(public/protected/private 继承)——类继承关系对外总是公开的,访问权限由成员自身的修饰符决定。也就是说,Java 不允许你继承一个类却降低其接口中方法的可见性。
当一个类既不在同一个包,又不是子类时,它无法访问 protected 成员,但可以访问 public 成员。当你希望一个方法或变量不作为公共接口,但又希望为子类提供一个可扩展或可定制的“钩子”时,使用 protected。
同时,父类的private对于子类是不可见的。子类如果与父类不在同一个包里,则父类的包级成员(无修饰符)对子类也不可见。
1 | // 父类 Animal |
构造与初始化
Java 子类构造方法的首语句必须调用父类构造(默认调用无参父构造),否则编译错误。父类构造执行完后,才执行子类自己的初始化。这一点上Java与C++一致,只是语法不同.
而且先字段构造,再调用构造方法。所有字段都确定初始值。
1 | // === 父类 === |
运行
1 | public class Main { |
输出
- 开始创建 Warrior 对象…
- 父类 GameCharacter 构造方法执行
此时父类 name = 默认角色, health = 100 - 子类 Warrior 构造方法执行
此时子类 name = 格罗姆·地狱咆哮, weapon = 血吼 - Warrior 对象创建完毕!
方法覆盖
Java 中所有非静态、非私有方法默认即为虚方法 (即支持动态绑定),不需要像 C++ 那样显式声明 virtual。
Java 提供@Override 注解,可让编译器检查这是一个有效的覆盖.@Override 注解保证了如果我们把子类方法名写错(如 attak()),编译器会报错,防止了我们意外地创建了一个新方法而非覆盖。
如果子类定义了父类中不存在的方法重载,同C++一样,这只是新增方法,不影响父类的。需要注意的是,Java 若定义了与父类同名不同参数的方法,子类会同时拥有父类和自己的版本。cpp会遮蔽父类的所有同名方法(包括不同参数的)
可以通过在子类中显式调用父类方法super.methodName(args) 来使用父类版本。
运行
1 | public static void main(String[] args) { |
hero 的编译时类型是 GameCharacter,但运行时类型是 Warrior
字段隐藏
如果子类声明了与父类同名的字段,这在Java中是合法的字段隐藏…父类引用访问的是父类字段,子类引用访问的是子类字段。
运行
1 | public static void main(String[] args) { |
这与方法覆盖完全不同!字段访问是在编译时根据引用的类型决定的。字段隐藏是极其不推荐的做法,它破坏了直觉,极易引发混淆。
协变返回
返回类型必须是相同或其子类型
1 | public static void main(String[] args) { |
Warrior 类的 findAlly() 方法返回 Warrior 类型,它是 GameCharacter 类的 findAlly() 方法返回类型 GameCharacter 的子类。这是合法的覆盖。这带来了便利,调用者可以直接得到更具体的子类型,而不需要像 Warrior ally = (Warrior)character.findAlly(); 这样进行强制类型转换。
2.5 friend
cpp
友元并非类的成员,而是一种单向声明关系 :A 将某函数/类声明为友元后,该函数/类可不受限制地访问A的私有和保护成员。但友元函数本身不是 A 的成员,不需要 A:: 限定来定义,也不能通过 A 的对象调用(除非本身正是一个成员函数调用)。友元常用于运算符重载(如重载 << 操作输出私有数据)或两个类紧密协作的情况。友元破坏了封装(因为暴露内部实现给特定实体),但在必要时提供了便利。需要慎用以免增加耦合。
如前
java
Java 没有 friend 关键字。Java 的访问控制无法破例允许非成员访问私有成员。
如果需要类似功能,通常通过包级私有机制或公有存取方法来实现
包粒度较大,所有同包类都能访问,而 friend 在C++中可以精确到一个类或函数。
更常见的是,通过提供 public 的 getter/setter 或其它方法来间接访问私有成员。需要跨类访问时,可以设计合理的接口。例如,要让类 B 读取类 A 的私有字段,可以在 A 中提供 public getVal() 方法返回该字段,B 调用即可。
反射 (Reflection): 虽然正常情况下无法访问私有成员,但 Java 提供反射API ( java.lang.reflect )可以在运行时绕过权限检查强行访问/修改私有字段和方法(通过 setAccessible(true)。这是一种非常规手段,类似于C++用指针强制改内存,但比友元更不安全且不推荐。它主要用于框架或工具需要访问对象内部状态的场景。
友元违反了面向对象封装原则
2.6 多态
cpp
virtual
静态绑定 vs 动态绑定: 默认情况下,C++ 函数调用是静态绑定的,也就是在编译时确定函数地址
1 | class A { public: void foo() { cout<<1; } }; |
即使 p 指向的是 B 对象,调用 p->foo() 也执行 A 的版
本 。这就是静态绑定,根据指针的静态类型(A*)决定调用 A 的函数。
在函数声明前加上关键字 virtual ,即可将其设置为虚函数,实现动态绑定 。动态绑定意味着真正调用的函数版本取决于对象的实际类型,而非指针/引用的静态类型
1 | class A { public: virtual void foo() { cout<<1; } }; |
这样就实现了 运行时多态:基类指针 p 在运行时判断出指向的是 B 对象,于是调用 B 的实现
通过维护一张虚函数表 (vtable) 来实现这一点,每个含虚函数的类有一张表存放其虚函数地址,每个对象存放一个指向所属类虚表的指针(通常称为 vptr ) 。调用虚函数时,通过对象的 vptr 找到对应函数地址再调用 。因此虚函数调用比普通函数略有开销(一次指针查找),但换来灵活性。
重要规则: 若一个类有任何可能被继承并通过基类指针删除的可能,其析构函数应定义为虚函数 。否则,通过基类指针删除派生对象时,只会调用基类析构,不会调用派生类析构,导致资源泄漏或未完全释放
纯虚函数
virtual ReturnType func(...) = 0;
要求派生类必须override提供具体实现,用于避免因函数签名不一致而没有实际覆盖的错误.
指针/引用
无论虚函数与否,直接用对象调用时调用该对象所属类的函数。
虚函数多态只有在使用基类指针或引用指向派生对象时才有意义
非虚函数:通过指针/引用调用时,绑定取决于指针/引用的编译时类型。基类指针调用基类实现,派生类指针调用派生实现(如果该函数在派生类中也定义,但这其实是隐藏不是重写)。
虚函数:通过指针/引用调用时,绑定取决于对象的运行时类型。指针指向基类对象则调用基类版本,指向派生对象则调用派生版本 。如果派生类未覆盖该虚函数,则调用基类的实现。派生类指针不能指向基类对象(除非强制转换,结果未定义行为)
1 | class B { |
上面输出顺序应为:
B::f
D::vf
B::f
D::vf
D::vf
B::f
这可见:
- pb->f() 因非虚所以用了 B::f,即使实际对象是 D。
- pb->ff() 绑定到 B::ff 实现,其中 vf() 调用了 D::vf(动态绑定), f() 调用了 B::f(ff是B的成员函数,
调用f名称绑定在编译期,此时ff是B类成员函数,所以f解析为B::f并静态绑定) 。 - pb->vf() 调用 D::vf(虚函数动态绑定)。
- pb->vff() 绑定到 B::vff(因为D没有override vff),vff内部按照其自身代码:调用 vf() 动态绑定D::vf,
调用 f() 静态绑定B::f 。 - 这些规则在C++中必须理解,否则会产生误用。Java 则简单得多,因为默认所有实例方法都是虚的,且不存在
调用非虚版本的陷阱:Java中类似 B.ff() 内部的 vf() 和 f() 由于都是虚方法(除非 f 是 private/final),所
以都会动态绑定到 D 实现。因此 Java 上述例子对应代码输出将是:
D::f
D::vf
D::vf
D::vf
D::vf
D::vf
区别
Java 将多态作为基本行为,更符合“里氏替换原则”,开发者无需额外关键词控制,大大降低了因忘记声明 virtual 导致错误的风险 。C++ 则提供更精细的控制:可以决定某些函数不允许覆盖(缺省情况),以获得优化;只有需要动态绑定时才付出代价。
2.7 强制类型转化
cpp
static_cast<新类型>(expr) : 用于各种显式类型转换,如数值基本类型间转换,类层次间向上转换(安全)或向下转换(需开发者确保正确) 。static_cast 不执行运行时类型检查,向下转型不安全,错误转换会导致未定义行为。它不能移除 const 限定,也不能转换不相关的类型(除非有转换运算符或构造函数定义)。但 static_cast 可用于将任何指针转换为 void* ,或从 void* 转回原类型指针。
const_cast<新类型>(expr) : 专门用于增加或去除 const/volatile 限定。可将 const T* 转为T* ,或 const T& 转为 T& 。使用场景如:某API函数参数没声明 const,但我们只有 const 数据,可用 const_cast 去掉 const 调用。不过如果强转掉 const 后修改数据原本是常量,会导致未定义行为。因此除非确信对象本身不是常量,才能安全地去掉 const。
reinterpret_cast<新类型>(expr) : 用于低级转换,直接按位重新解释一个指针或整数。典型用法如将指针转换成整数类型(比如打印地址)或反之。它可以在不相关的指针类型之间转换,如把一个对象指针转成完全不相干类型的指针。但结果取决于实现并不一定有意义,常用于底层驱动、序列化需要。reinterpret_cast 是最不安全的转换,一般高层代码少用。
dynamic_cast<新类型>(expr) : 用于安全的向下转换和跨类层次转换。它只能用于含有虚函数的多态类(必须有 RTTI 信息),其原理是在运行时检查对象真实类型 。如果 expr 所指对象实际是新类型或其子类型,则 dynamic_cast 返回该类型的指针,否则返回 nullptr (对引用类型转换失败则抛出 std::bad_cast 异常)。dynamic_cast 不能用于基本数据类型或不相关的类间转换 。和static_cast 相比,dynamic_cast 向下转换更安全,但需运行时开销。若基类没有虚函数(无RTTI),dynamic_cast 编译就会报错
1 | class A { public: virtual ~A(){} }; |
假设 A 为多态基类(有虚析构),运行时:
- OK1 判断输出 “Fail”,因为 ap 实际指向 A 对象,无法 dynamic_cast 成 B(返回null)。
- OK2 判断输出 “OK2”,因为 static_cast(ap) 编译器允许,返回非空指针,但其实指向的内存不是一个真正的 B 对象,这个指针是无效的但不为null,于是 if判断为真。这证明 static_cast 下行转换不做检查,危险。
- OK3 判断输出 “OK3”,ap 指向 B 对象时 dynamic_cast 成功,返回有效 B*。
- OK4` 判断输出 “OK4”,ap 指向 B 对象时 static_cast 也返回 B*(其实就是原指针地址),自然非空。
上述结果 表明:dynamic_cast 在类型不匹配时返回 NULL 指示失败,而 static_cast 不管三七二十一转换指针类型,可能产生悬空的无效指针却依然非空,从而造成程序误判 。因此,建议在需要安全下行转换时使用 dynamic_cast,并确保基类带有虚函数使其可用。static_cast 只在明确知道转换合法时使用,如向上转换或基本类型转换。reinterpret_cast 则尽可能避免,除非做底层操作。
java
Java 的类型转换分两类:基本类型转换 和 引用类型转换:
数值类型转换:
Java 允许兼容的数值类型之间转换。小范围类型可以自动提升到大范围类型(如 int 自动提升为 long/double),称为宽化转换(widening conversion),这是安全的不会溢出。反之,大类型转换为小类型需要显式强制转换(如 (int)3.5 得到3),这是窄化转换,可能丢失信息,编译要求强转来表示你知晓风险。如果没有强转,编译报错。例如:
1 | int i = 10; |
整型和浮点型之间转换也遵循此规则,double->int必须强转等。Java 不区分 const 类型,因此不存在const_cast。一切数值转换都是值拷贝。
引用类型转换(类/接口)
Java 只有单一继承,但有接口多实现,所以类型转换有三种情况:向上转型、向下转型、跨接口/类转型。
向上转型 (Upcasting)
子类对象赋给父类引用是隐式安全的,无需强转。例如 B extends A ,可以 A a = new B(); 。这是因为 B 是 A 的一种,父类引用能代表子类对象。这类似 C++的向上转换,可以隐式进行且安全。接口方面,类实现接口,实例赋给接口引用也可隐式转换:List list = new ArrayList(); 。
向下转型 (Downcasting)
父类引用如果实际指向子类对象,可强制转换为子类类型引用。
1 | A a = new B(); |
Java 在运行时会检查这个转换是否可能:如果 a 实际不是指向一个 B 类型对象,转换将抛出ClassCastException 。因此,Java 的向下转型相当于 C++ 的 dynamic_cast 行为——具有运行时类型检查,保证安全。开发者可以在强转前用 instanceof 检查:
1 | if(a instanceof B) { |
这类似 C++ 中先 dynamic_cast 判空再用。其实 Java 的强制转型本质上包含了一个内部的instanceof 检查,不通过就抛异常。因此在正确逻辑中只要转型对象类型匹配就不会有问题。
向下转型在Java中必须手动写强转运算 (Type)obj ,编译器不会自动做,以防不安全。
不相关类型转换
Java 不允许把毫无继承关系的类型互相转换,比如想把 String 转换成StringBuilder 会编译错误。而 C++ 在编译层面也不允许不相关指针 static_cast,除非用reinterpret_cast (Java 没有这种武器)。Java 通过泛型提供编译期类型安全(但有类型擦除限制),通常不需要类似 reinterpret_cast 的机制。
接口与类转换
类实现接口,接口引用强转成实现类引用,或者反之,都算上下转型,原则一样。特别地,如果一个对象实现了多个接口,接口之间转型需要先转成实现类或公共父接口。例如:
1 | interface X {} |
- 为什么编译器允许 (Y) x?
当编译器看到 Y y = (Y) x; 这行代码时,它的思考过程是这样的:
“我知道变量 x 的类型是接口 X。”
“程序员想把它转成接口 Y。”
“X 和 Y 本身没有继承关系,它们是两个独立的接口。”
“但是,有没有可能存在一个类,它同时实现了 X 和 Y 两个接口呢?”
“答案是肯定的,比如例子中的 C 类,或者未来可能出现的任何一个 class D implements X, Y。”
因为存在这种可能性,编译器不能武断地认为这个转换是错误的。它无法在编译阶段预知 x 在运行时到底会指向哪个具体对象。所以,编译器选择信任程序员,允许这次编译,同时默默地埋下一个“运行时检查”的指令。
- 运行时检查做了什么?
当 JVM 执行到 Y y = (Y) x; 这行代码时,它的操作就非常具体了:
获取真实对象:JVM 查看引用 x 当前在内存中指向的实际对象。在这个例子中,它指向的是一个 new C() 的实例。
检查继承/实现关系:JVM 会问一个问题:“这个 C 类的实例,是不是 Y 接口的一个实现?” (Does C implement Y?)。
做出裁决:
情况一(成功):JVM 检查 C 类的定义,发现 class C implements X, Y。答案是肯定的!C 确实实现了 Y 接口。因此,类型转换成功,程序继续执行,变量 y 会正确地指向那个 C 对象。
情况二(失败):这正是您问题的后半部分——“否则抛异常”。
数组转换
协变(Covariance)意味着,如果 Dog 是 Animal 的子类型,那么 Dog[] 也被看作是 Animal[] 的子类型。这允许我们将一个子类数组赋值给父类数组的引用。
Java数组是协变的, Sub[] 可以赋给 Base[] (因为 Sub extends Base),但这里存在运行时数组存储检查。Java 数组会记住元素实际类型,如果不匹配插入会抛 ArrayStoreException 。这一设计被认为不太安全(泛型没有采用协变数组策略)。
C++数组则无这层检查。
1 | // 1. 定义一个简单的继承体系 |
泛型类型
Java 泛型采取了与数组完全相反的策略:不变性(Invariance)。List
1 | import java.util.ArrayList; |
3 重载
3.1 函数的重载
cpp
在同一作用域(同一个作用域或类)中,声明多个同名函数但参数类型或个数不同,它们即构成重载关系 。调用时编译器根据实参类型和个数选择匹配度最高的函数
- 返回值类型,顶层 const (修饰返回值或值传递参数)不参与重载判别.底层 const(指针或引用参数指向常量)会视为签名不同。例如
void f(int*)vsvoid f(const int*)是有效重载 - 重载决议顺序 :编译器会寻找最佳匹配:首先完全匹配的非模板函数>完全匹配的模板函数>需要提升/转换的函数。如果有多个候选都同等匹配,则报“调用不明确”错误 。如果没有找到可行的,则报“无匹配函数”错误。
- 类型转换与重载:编译器在匹配参数时,可考虑标准类型转换(如 int->double)或用户自定义转换(如构造函数或转换运算符)。如果多个重载都能通过不同转换匹配,会导致二义性。第三个调用 foo(1,2.0) ,第一个重载需要将2.0转换为int,第二个重载需要将1转换为double。两者都可匹配,各需一次转换,没有明显优劣,所以编译会报模糊错误
1
2
3
4void foo(int a, int b) { cout<<1; }
void foo(double a, double b) { cout<<2; }
// void foo(int a, double b) { ... } 假设没有这个版本
foo(1, 2.0);
java
Java 也支持方法重载,与 C++ 规则类似:同一类中方法名相同但参数列表不同即可重载。
- 返回类型不参与判定,必须有不同参数列表。否则编译报重复定义错误。
- 重载选择在编译期完成,根据实参静态类型选择最合适的方法。这一点与C++相同。Java会应用自动转换来尝试匹配,例如实参 int 可匹配形参long(widening),实参实现接口可匹配接口类型参数等。如果多个重载都可匹配且没有一个更优,就编译错误。
- 自动提升 & 拆装箱 & 可变参数:Java比C++多了装箱/拆箱和可变参数的概念。先找最具体匹配,再拓宽原始类型,再考虑装箱,再考虑可变参数。若歧义无法消除则报错。例如,有重载foo(Integer) 和 foo(int…) 两种,调用 foo(5) 会如何?5 是 int,可匹配 foo(int…)(可变参数,foo 方法可以接受零个或多个 int 类型的参数,是数组,一个方法最多只能有一个可变参数,可变参数必须是方法的最后一个参数)直接,也可通过装箱为 Integer 匹配 foo(Integer)。这时会调用 foo(Integer)在Java中,1 可转为 double,2.0 是 double,匹配 foo(double,double);或者 2.0 转为 int (窄化,不允许自动发生)。所以只有 foo(double,double) 是可行重载,因此调用输出2。这个和C++的例子不同:Java不允许双->int的隐式转换,消除了二义性。
1
2
3
4
5例如:
void foo(int a, int b) { System.out.println(1); }
void foo(double a, double b) { System.out.println(2); }
// 调用:
foo(1, 2.0); - 重载解析不考虑运行时类型——这是重载与重写的重要区别:重载在编译期决定,重写(覆盖)在运行期决定。如果通过父类引用调用一个重载方法,首先根据引用的静态类型选择匹配的重载版本,然后在该版本中如有多态再动态绑定。这里 a 是 A 类型引用, foo(“hello”) 编译时会选 A 类的 foo(Object)(因为只有那个存在,对A而言B的foo不可见),运行时对象实际是 B,但 B 没有覆盖 foo(Object)(它定义的是不同签名foo(String)),所以仍调用 A 的 foo(Object),输出 “A:Obj”。这例子说明Java重载不具备多态性(只能按静态类型选),而 C++ 情况类似,如果 B 定义额外重载而非覆盖虚函数,通过基类指针也调用基类版本。这再次强调:要实现多态,应使用覆盖而非重载。
1
2
3
4
5
6
7
8
9
10class A {
void foo(Object x) {
System.out.println("A:Obj");
} }
class B extends A {
void foo(String x) {
System.out.println("B:Str");
} }
A a = new B();
a.foo("hello");
对比
对比: Java 与 C++ 的函数重载概念几乎相同,区别主要在于:Java 自动类型转换规则略有不同(无隐式窄化,添加了装箱/拆箱),但本质是类似的匹配优先级、模糊错误等。Java由于无默认参数,往往需要定义多个重载来实现类似功能。C++可以用默认参数减少重载数(Java通过方法重载或使用可变参数实现)。C++模板函数也可与重载竞争,而Java泛型在编译后类型擦除,不参与重载选择(即重载方法只能靠参数数量或原始类型区分,泛型参数只是引用类型,对重载而言原始类型相同)。总的来说,开发者需避免让重载过于模糊,以免使用时不确定选哪一个。
3.2运算符重载
cpp
C++ 允许对大部分内置运算符进行重载,使其作用于用户自定义类型时执行特定操作。运算符重载本质上是定义特殊名字的函数。
- 并非所有运算符都可重载:不能重载 . 成员访问、 :: 作用域、 ?: 三目运算符,以及 sizeof 、typeid 等特殊运算符 。大多数其他比如 + - * / % 等都能重载。也不能创造新运算符,只能重载已有的。
- 重载必须保持原运算符的元数和优先级不变 。例如 + 是双目运算符,重载后也只能是双目,无法让它变成一元。重载不改变运算符优先级/结合性——这些是编译器静态决定的,与重载函数无关。
运算符重载的实现方式:
函数名形式为 operator@ ,其中 @ 替换为运算符符号。
重载为成员函数
Complex operator+(const Complex& rhs) const { ... }实现复数加法。
作为成员函数,左操作数是隐含的 this (必须是类类型),右操作数作为参数。单目运算符成员函数无参数(如 operator-() 一元负号),双目有一个参数 .
重载为全局函数
参数个数与运算符目数一致(如双目两个参数) 。非成员重载往往需要访问类的私有成员,这时可将该函数声明为类的友元
一般经验
赋值 = 、下标 [] 、函数调用 () 、成员访问箭头 -> 这些运算符必须定义为类的成员函数,因为它们需要特殊语义(C++规定这些只能成员重载) 。
前置++/– 通常成员实现,后置++/– 通过一个 int 哑参数与前置区分 例如 Integer operator++(int) 。
其他如算术、比较运算符可灵活选择成员或非成员。通常将左操作数需要隐式转换的情况做非成员更灵活,例如 operator<< 和 operator>> 通常实现为非成员,以允许左边是 ostream 。
比较运算符复用: 为减少重复代码,通常实现 operator== 和 operator< 等基本比较,然后在其他比较运算符中调用它们 。C++20 提供了三向比较运算符 <=> 可自动生成其他比较。
转换运算符:
类可以定义转换为其他类型的运算符,如 operator double() const { … } 用于将Complex转换为double 。这样可以支持隐式或显式将对象转换为目标类型。不过隐式转换可能导致二义性或意外行为,因此可考虑标记 explicit 来避免自动发生隐式转换(C++11起可对转换运算符用explicit)。
java
Java 不支持运算符重载。所有运算符的功能对自定义类型来说都是固定的。
只有一个例外: +运算符被语言内置地重载用于字符串拼接。当一个字符串与另一个字符串或其他类型用 + 相连时,会触发 StringBuilder 拼接过程。例如: “Hello “ + 5 会将5转换为字符串并连接,得到 “Hello5” 。这个功能并非通过用户定义,而是Java编译器遇到字符串+就自动转换处理。
对于自定义类型的相等比较,Java提供 .equals() 方法(所有类继承自 Object 默认提供 equals ,可覆盖)用于语义相等性判断;哈希值通过 .hashCode() ;排序比较通过实现 Comparable
对于类似复数相加、矩阵相乘这些,在Java中只能定义方法如 add() 、 multiply() ,用方法调用代替运算符。虽然稍显啰嗦,但Java设计者认为这换来代码的简洁统一和避免歧义。
然而,这也让Java代码在处理数学运算时不如C++简洁直观。例如C++可以写 c = a + b; 对于Complex,而Java必须 c = a.add(b); 。为了可读性,Java引入了Records和重写toString()等手段改善表现,但仍不及运算符重载直接。
另外,需要注意Java的 + 拼接字符串在编译后会转换成 StringBuilder 操作,效率较低时可以手动使用 StringBuilder 或 String.format 优化。
3.3 输入输出流的重载
cpp
ostream& operator<< (ostream& os, const T& obj);istream& operator>> (istream& is, T& obj);
典型实现模式是非成员友元函数,以便访问对象私有数据并与流对象交互。约定这类重载应返回流引用本身,以支持链式操作 。例如:
1 | class Point { |
1 | Point a{1,2}, b; |
java
输出:使用 System.out.print() / println() 打印基本类型和对象(调用其 toString() )。或者使用 PrintWriter 、 BufferedWriter 的 .write() 、 .print() 方法输出。Java 还提供了String.format 或 Formatter 来格式化字符串然后输出,相当于 C++ 的 printf 风格。Java 8 也引入了流式 API(java.util.stream),但那是用于集合处理,不是I/O。
输入:使用 Scanner 辅助从 InputStream (如 System.in)读取基本类型和字符串,或用BufferedReader 逐行读文本,然后再解析。也可以使用DataInputStream或 ObjectInputStream读取二进制数据。总之,没有像C++那样直接 cin >> x 的语法糖,只能调用方法如scanner.nextInt()
如果想给自定义类型实现类似 << 的方便输出,Java的方式是覆盖 toString() 方法:当对象用于字符串环境或被 PrintStream打印时,会自动调用其 toString()。例如
1 | class Point { int x,y; |
这会输出 Point p: (1, 2) ,因为 p 用 + 拼接字符串时 Java 调用了 p.toString()
4 模板 (Templates) 与泛型 (Generics)
4.1 namespace
cpp
命名空间用于将标识符划分在不同空间,避免全局命名冲突.
语法: namespace Name{ … } 来声明。使用时可以以 Name::identifier 访问,或通过 using namespace Name; 引入全部名称 。命名空间可以嵌套,可以在多个块中打开同名命名空间(追加内容)。
C++标准库本身就在 std 命名空间中。因此我们经常用 using namespace std; 或 std:: 限定来使用标准库功能。
1 | namespace space1 { string name = "randomstar"; } |
java
Java 没有与命名空间完全对等的概念,但包 (Package) 实现了类似功能
Java 用 packagecom.example.myapp;声明类所属的包,用 import com.example.myapp.ClassName; 引入别的包中的类以便使用短名。包名通常以公司域名反转开头,确保全球唯一性,从而避免命名冲突。import com.foo.*; 导入 foo 包下所有类的短名,但不会导入子包;import com.foo.Bar; 只导入指定类 Bar;
java 没有导入“所有”功能,也没有类似C++ using namespace直接把标识符引入当前作用域,如果冲突需全限定名。
例如,如果同时有 java.util.Date 和 java.sql.Date 两个类,import java.util. 和 import java.sql.同时导入,会导致 Date 类名冲突,此时必须写全称或只 import 一个。Java 不能像C++那样为包起别名或选择性导入函数。
由于Java没有全局函数,包主要用来隔离类名。内部原理上,包也决定访问权限:默认权限下,同包类可互访对方的无修饰成员。C++命名空间不涉及权限,仅仅是名字分类。





