精進Java——面向物件上篇
這篇文章碼了好幾天,也是我準備開的一個新系列,是十月的目標,既然名字是《精進Java》,那麼我也會也對各個點都進行深入瞭解, 當然有些比較基礎的我就直接跳過了。到時我會搭配自己總結的面試題一起閱讀(畢竟一篇文章不能太長了哈哈,面試題是否開放到時再看吧),如果這裡有沒有總結到的知識點,希望各位讀者能給我提提意見,我會加上我自己的理解和總結,後期還會根據變化不斷補充內容。碼字不易,都是一個字一個字敲出來的,希望各位大佬們覺得不錯的話可以多關注我的文章,你們的每一個評論和收藏都是我的動力!!
面向物件和麵向過程的區別
面向過程適合簡單、不需協作的事務,按步驟實現,比如如何開車?
面向物件更趨向於的是如何設計,而不是按步驟進行,比如如何造車?
這兩種都是解決問題的思維方式,都是程式碼組織的方式。
解決複雜的問題,巨集觀上使用面向物件把握,微觀處理上仍然是面向過程。
面向物件三大特性
封裝、繼承、多型。後面會拆開講解。
記憶體分析
Java虛擬機器的記憶體可以分為三個區域:棧stack、堆heap、方法區method area
棧的特點
- 棧描述的是方法執行的記憶體模型,每個方法被呼叫都會建立一個棧幀(儲存區域性變數、運算元、方法入口),每個方法執行完就幹掉這個棧幀
- JVM為每個執行緒建立一個棧,用於存放該執行緒執行方法的資訊(實際引數、區域性變數等),當這個執行緒的方法執行完,那麼就把這整個棧幹掉
- 棧屬於執行緒私有,不能實現執行緒間的共享
- 棧的儲存特點是“先進後出,後進先出”
- 棧是由系統自動分配的,速度快,棧是一個連續的記憶體空間
堆的特點
- 堆用於儲存建立好的物件和陣列(陣列也是物件)
- JVM只有一個堆,被所有執行緒共享
- 堆是一個不連續的記憶體空間,分配靈活,速度慢
方法區(靜態區)特點
- JVM只有一個方法區,被所有執行緒共享
- 方法區實際也是堆,只是用於儲存類、常量相關的資訊
- 用來儲存程式中永遠不變或唯一的內容(類資訊、class物件、靜態變數、字串常量等等)
public class Student{ int id; //學號 String name; //姓名 int age; //年齡 School school; //所在學校 void study(){ System.out.println("好好學習天天向上"); } void sleep(){ System.out.println("好好休息"); } } public static void main(String[] args){ Student student = new Student(); student.id = 1111; student.name = "tihom"; student.age = 18; School school = new School; school.name = "GDUT"; student.school = school; } class School{ String name; }
這裡反映了記憶體間的關係
垃圾回收機制
拿C++與Java對比,C++好比沒有服務員的飯店,每桌吃完之後沒人收拾垃圾,那麼隨著店內乾淨的桌子不斷減少,最後沒有乾淨的桌子可以使用了;而Java則自帶一個GC服務員,會在每桌吃完之後主動收拾垃圾。
垃圾回收演算法一般要做的兩件事
- 發現無用的物件
- 回收無用物件佔用的記憶體空間
所使用的演算法
-
引用計數演算法
堆中每個物件都有一個引用計數。被引用一次計數+1,被引用變數變為null,則計數-1,直到計數為0,則表示變成無用物件,演算法簡單,但是”迴圈引用的無用物件“無法被識別
-
引用可達演算法(根搜尋演算法)
程式把所有的引用關係看作一張圖,從一個節點GC ROOT開始,尋找對應的引用節點,找到這個節點之後,繼續尋找這個節點的引用節點,當所有的引用節點尋找完畢之後,剩餘的節點則認為是沒有被引用到的節點,即無用的節點
分代垃圾回收機制
不同物件有不同的生命週期。因此,不同生命週期的物件可以採取不同的回收演算法,以便提高回收效率。我們將物件分為三種狀態:年輕代、年老代、持久代。JVM將堆記憶體劃分為 Eden、Survivor 和 Tenured/Old 空間。
- 年輕代
所有新生成的物件首先都是放在Eden區。 年輕代的目標就是儘可能快速的收集掉那些生命週期短的物件,對應的是Minor GC,每次 Minor GC 會清理年輕代的記憶體,演算法採用效率較高的複製演算法,頻繁的操作,但是會浪費記憶體空間。當“年輕代”區域存放滿物件後,就將物件存放到年老代區域。
- 年老代
在年輕代中經歷了N(預設15)次垃圾回收後仍然存活的物件,就會被放到年老代中。因此,可以認為年老代中存放的都是一些生命週期較長的物件。年老代物件越來越多,我們就需要啟動Major GC和Full GC(全量回收),來一次大掃除,全面清理年輕代區域和年老代區域。
- 持久代
用於存放靜態檔案,如Java類、方法等。持久代對垃圾回收沒有顯著影響。
-
Minor GC
用於清理年輕代區域。Eden區滿了就會觸發一次Minor GC。清理無用物件,將有用物件複製 到“Survivor1”、“Survivor2”區中(這兩個區,大小空間也相同,同一時刻Survivor1和Survivor2只有一個在用,一個為空)
-
Major GC
用於清理年老代區域。
- Full GC
用於清理年輕代、年老代區域。 成本較高,會對系統性能產生影響。
垃圾回收過程
1、新建立的物件,絕大多數都會儲存在Eden中,
2、當Eden滿了(達到一定比例)不能建立新物件,則觸發垃圾回收(GC),將無用物件清理掉,
然後剩餘物件複製到某個Survivor中,如S1,同時清空Eden區
3、當Eden區再次滿了,會將S1中的不能清空的物件存到另外一個Survivor中,如S2,
同時將Eden區中的不能清空的物件,也複製到S1中,保證Eden和S1,均被清空。
4、重複多次(預設15次)Survivor中沒有被清理的物件,則會複製到老年代Old(Tenured)區中,
5、當Old區滿了,則會觸發一個一次完整地垃圾回收(FullGC),之前新生代的垃圾回收稱為(minorGC)
JVM調優和Full GC
在對JVM調優的過程中,很大一部分都是對Full GC的調優
有如下原因可能導致Full GC
-
年老代(Tenured)被寫滿
-
持久代(Perm)被寫滿
-
System.gc()被顯式呼叫(程式建議GC啟動,不是呼叫GC)
-
上一次GC之後Heap的各域分配策略動態變化
記憶體洩漏
為什麼會發生記憶體洩漏?
A引用了B,A的生命週期為t1-t4,B的生命週期為t2-t3,當B不使用時,A仍然保持著對B的引用,垃圾回收機制無法對B進行清理,導致B一直在記憶體中存在,如果很多個這種存在的話,那麼記憶體會消耗很大的空間。
也有情況是B引用著很多個其他的物件,那些物件無法被銷燬,導致A引用B,B引用了其他物件都無法被回收。
-
建立大量的無用物件
比如,使用字串拼接時,使用了String而不是StringBuilder
-
靜態集合類的使用
像HashMap、Vector、List等的使用最容易出現記憶體洩露,這些靜態變數的生命週期和應用程式一致,所有的物件Object也不能被釋放
-
各種連線物件未關閉(IO流物件、資料庫連線物件、網路連線物件)
這些連線物件都是物理物件,和硬碟或者網路連線,不使用時一定要關閉
-
監聽器的使用
釋放物件時,未刪除對應的監聽器
注意
- 可以呼叫System.gc(),但是該方法只是通知JVM,並不是執行垃圾回收器。儘量少用,會申請啟動Full GC,成本高,影響系統性能
- finalize方法,是Java提供給程式設計師用來釋放物件或資源的方法,但是儘量少用
this和this()
this指的就是當前物件,this()指的就是當前物件的構造方法
public class Main{
int a,b;
Main(int a,int b){
this.a = a;
this.b = b;
}
Main(int a,int b,int c){
this(a,b);
c = a+b;
}
}
static
static修飾的方法是屬於類的,而普通的方法是屬於例項(物件)的,所以static修飾的方法不能直接呼叫普通的方法,因為普通的方法需要物件去呼叫
核心就是
static修飾的成員變數和方法,從屬於類。
普通變數和方法從屬於物件。
普通的方法可以呼叫static修飾的方法和變數,但是static修飾的並不能直接呼叫普通方法和變數。
靜態初始化塊
構造方法用於物件的初始化;靜態初始化塊,用於類的初始化操作;所以先執行的是初始化塊,沒有類也就沒有了物件。在靜態初始化塊中不能直接訪問非static成員。
注意
靜態初始化塊執行順序
-
上溯到Object類,先執行Object的靜態初始化塊,再向下執行子類的靜態初始化塊,直到我們的類的靜態初始化塊為止。
-
構造方法執行順序和上面順序一樣
引數傳值機制
Java內方法的引數都是使用值傳遞
,而值傳遞本身又傳遞的是值的副本
,所以我們得到的都是影印件而非原件,所以影印件的改變並不影響原件。
基本資料型別的傳值
傳遞的是值的副本,副本改變不會影響原件。
引用型別引數的傳值
傳遞的是值的副本,但是引用型別指的是“物件的地址”。因此,副本和原引數都指向了同一個“地址”,改變“副本指向地址物件的值,也意味著原引數指向物件的值也發生了改變”。
比如
public class User {
int id; //id
String name; //賬戶名
String pwd; //密碼
public User(int id, String name) {
this.id = id;
this.name = name;
}
public void testParameterTransfer1(User u){
u.name="tihom1";
}
public void testParameterTransfer2(User u){
u = new User(200,"tihom2");
}
public static void main(String[] args) {
//建立一個User物件,在堆記憶體中產生這個物件的記憶體空間,裡面存的值是100、tihom3,然後u1在棧中引用這個物件,指向的地址假設是123
User u1 = new User(100, "tihom3");
//這裡的引數u1是副本,進入方法中,u指向的地址也是123,所以將name的值更改了
u1.testParameterTransfer1(u1);
System.out.println(u1.name); //tihom1
//這裡傳入的u1是副本,但是在方法內重新建立了新的記憶體空間(地址為124),所以u指向的地址為124並非123
u1.testParameterTransfer2(u1);
//但是這裡仍然用的是u1的引用,而u1的值在testParameterTransfer2方法中並未被改變,所以結果依然是tihom1
System.out.println(u1.name);
}
}
import
靜態匯入:import static是用來直接引入類中的靜態屬性的
繼承
繼承的概念應該都很瞭解了,注意的就是Java中類只有單繼承,介面有多繼承,且類如果沒extends如何類的話,那麼預設繼承的是Object類,Object類是所有類的根基類
重寫
需要注意的幾個點
-
方法名、形參列表相同
-
返回值型別和宣告異常型別,子類小於等於父類
-
訪問許可權,子類大於等於父類
instanceof
instanceof是二元運算子,左邊是物件,右邊是類,當物件是右邊類或子類所建立的物件時,返回true,反之,false
使用a instanceof b
toString方法
Object類中的toString方法原始碼如下
public String toString() {
return getClass().getName() + "@" + Integer.toHexString(hashCode());
}
表示會輸出類名[email protected]+16進位制的hashCode
,在print或者字串連線時會呼叫該方法。
所以,一般在構造物件時,我們會重寫toString方法,在列印時能更清晰的顯示物件資訊。
”==“、equals方法、hashCode()
對於字串常量來說,使用"=="和equals方法比較字串時
-
“==”比較兩個變數本身的值,即兩個物件在記憶體中的地址
-
equals方法比較的是字串中所包含的內容是否相同
對於非字串變數來說,使用“==”和equals方法的作用是相同的
- 都是用來比較其物件在堆記憶體的首地址,即用來比較兩個引用變數是否指向同一個物件
Object中有equals方法,比較的是物件內容是否相等,比如通過身份證號碼、學號等等確定是否是同一個人。
Object中的equals方法預設比較的是兩個物件的hashCode,是同一個物件的引用時返回true,否則返回false,同時,我們可以根據自己的不同需求重寫equals方法
複寫equals時需要注意的準則
- 自反性(reflexive)。對於任意不為
null
的引用值x,x.equals(x)
一定是true
。 - 對稱性(symmetric)。對於任意不為
null
的引用值x
和y
,當且僅當x.equals(y)
是true
時,y.equals(x)
也是true
。 - 傳遞性(transitive)。對於任意不為
null
的引用值x
、y
和z
,如果x.equals(y)
是true
,同時y.equals(z)
是true
,那麼x.equals(z)
一定是true
。 - 一致性(consistent)。對於任意不為
null
的引用值x
和y
,如果用於equals比較的物件資訊沒有被修改的話,多次呼叫時x.equals(y)
要麼一致地返回true
要麼一致地返回false
。 - 對於任意不為
null
的引用值x
,x.equals(null)
返回false
。
hashCode()方法
在Object類中,hashCode方法的原始碼如下
public native int hashCode()
說明這是一個本地方法,它的實現是根據本地機器相關的。當然我們可以在自己的類中複寫hashCode方法,比如String、Integer、Double等這些類都是複寫了hashCode方法。下面是String中的實現
public int hashCode() {
int h = hash;
if (h == 0) {
int off = offset;
char val[] = value;
int len = count;
for (int i = 0; i < len; i++) {
h = 31 * h + val[off++];
}
hash = h;
}
return h;
}
hashCode的作用
hashCode與Java集合的聯絡比較明顯,Java集合有兩類,List和Set,List集合中的元素是有序的,而Set中的元素是無序的,那麼是怎麼做到元素不重複的呢,判斷的方法是什麼?
使用的是Object中的equals方法,每增加一個元素就進行一次比較,但是如果資料量大起來之後,沒增加一個都進行比較的話,效率會很慢,比如現在有2000個元素,那麼增加一個元素就要equals比較2000次,任何人都不會設計出這種不科學的方法,所以Java採用了雜湊表的原理。
使用雜湊演算法(雜湊演算法),主要通過求模、異或、移位來實現,有興趣的可以去更深入的瞭解一下。根據演算法找到特定的地址,如果地址位置沒有元素,那麼直接儲存在這個地址上,如果這個地址位置已經有元素,那麼就需要呼叫equals方法,若相同就不存了(因為這裡做到的是不重複),不相同的話就雜湊到別的地址上。所以這裡可能會出現雜湊衝突問題
雜湊衝突
由於雜湊演算法被計算的資料是無限的,而計算後的結果範圍有限,因此總會存在不同的資料經過計算後得到的值相同,這就是雜湊衝突
解決雜湊方法的方法
-
開放定址法(線性探測再雜湊、平方探測再雜湊)
線性探測再雜湊很好理解,就是算出來的地址上如果已經存在元素了,那麼就在表格上往後走,假設0位置上衝突了,並且1、2有元素而3沒有,那麼就將0處衝突的元素存在3處,以此類推,後面有衝突的也是這樣解決。
平方探測再雜湊也不難,假設在1的位置上衝突了,那麼就計算,如果2的位置上是空的,那麼就存在2上。如果在2上衝突了,那麼就計算$2+1^2=$3,3位置有元素,那麼計算,如果還是有元素,那麼計算,有人就繼續計算,-2就相當於這個有限表的倒數第二個位置,以此類推下去…
-
鏈地址法
將所有雜湊地址相同的記錄都連結在同一連結串列中,具體可以看hashMap的原始碼
-
再雜湊法
算出來重複了,那麼就用另外一個演算法去算,直到不重複為止。(猜測)
-
建立公共溢位區法
就是不將資料存在那個表中了,放在另外的地方。(猜測)
hashCode()和equals()的聯絡
根據資料查詢和上面講的這麼多東西可以得出來的歸納
-
若重寫了equals(Object obj)方法,則有必要重寫hashCode()方法。
-
若兩個物件equals(Object obj)返回true,則hashCode()有必要也返回相同的int數。
-
若兩個物件equals(Object obj)返回false,則hashCode()不一定返回不同的int數,但為不相等的物件生成不同hashCode值可以提高 雜湊表的效能
-
若兩個物件hashCode()返回相同int數,則equals(Object obj)不一定返回true。
-
若兩個物件hashCode()返回不同int數,則equals(Object obj)一定返回false。
-
同一物件在執行期間若已經儲存在集合中,則不能修改影響hashCode值的相關資訊,否則會導致記憶體洩露問題。
-
hashCode是為了提高在雜湊結構儲存中查詢的效率,線上性表中沒有作用。
這裡與Java集合的密切關係我準備到時在精進Java集合的時候進行講解,敬請關注嘿嘿嘿
繼承樹的追溯
構造方法第一句總是:super(…)來呼叫父類對應的構造方法,先追溯到Object類,依次向下執行類的初始化和構造方法,直到當前子類為止。
靜態初始化塊的呼叫與構造方法的呼叫流程一致。
封裝
訪問控制符
物件屬性一般使用的是private訪問許可權,一些只在本類使用的輔助方法也使用private,set/get類的需要外面呼叫來賦值與讀取操作的還是使用public
多型
多型指的是一個方法的呼叫,不同物件有不同的處理方案。比如我同樣呼叫“吃飯”這個方法,中國人會用筷子吃飯,美國人會用叉子吃飯,摩洛哥人會用手抓飯。
多型的要點
-
多型是方法的多型,不是屬性的多型
-
多型的存在有三個必要的條件
- 繼承
- 方法重寫
- 父類引用指向子類物件
-
父類引用指向子類物件後,用該父類引用呼叫子類重寫的方法
多型提高了程式碼的可擴充套件性,符合開閉原則。但是多型也有弊端,父類無法呼叫子類特有的方法。不過,如果要使用子類的特有方法,可以使用物件的轉型
物件的轉型
理解何為向上轉型和向下轉型只需要通過程式碼即可清晰瞭解
public class TestCasting {
public static void main(String[] args) {
Object obj = new String("tihom"); // 向上可以自動轉型
// obj.charAt(0) 無法呼叫。編譯器認為obj是Object型別而不是String型別
// 編寫程式時,如果想呼叫執行時型別的方法,只能進行強制型別轉換,不然通不過編譯器的檢查
String str = (String) obj; // 向下轉型
System.out.println(str.charAt(0)); // 位於0索引位置的字元
System.out.println(obj == str); // true.他們倆執行時是同一個物件
}
}
在進行強制型別轉換之前,先用instanceof運算子判斷是否可以成功轉換,從而避免出現ClassCastException異常
抽象方法和抽象類
何為抽象類?
就是你無法具體的描述出這個類代表的是什麼,你需要其他類來說明,比如我們定義一個Animal動物類,我們只知道這是動物,但是具體是什麼動物呢,我們需要其他類來說明,假如我在Animal類中定義了talk方法
public abstract class Animal{
public abstract void talk();
}
public class Dog extends Animal{
@Override
public void talk(){
System.out.println("汪汪汪~");
}
}
public class Cat extends Animal{
@Override
public void talk(){
System.out.println("喵喵喵~");
}
}
public static void main(String[] args){
Animal a1 = new Cat();
Animal a2 = new Dog();
//Animal a3 = new Animal(); 這樣是錯誤的,無法例項化,只能交給子類
a1.talk();
a2.talk();
}
這就是很經典的抽象方法和抽象類,抽象類中定義的抽象方法必須要子類去重寫
注意的點
-
抽象類不能被例項化,例項化的工作交給子類去完成,抽象類只需要有一個引用即可。
-
抽象方法必須由子類來進行重寫。
-
只要包含一個抽象方法的抽象類,該方法必須要定義成抽象類,不管是否還包含有其他方法。
-
抽象類中可以包含具體的方法,也可以不包含抽象方法。
-
子類中的抽象方法不能與父類的抽象方法同名。
-
abstract不能與final並列修飾同一個類和方法。
-
abstract 不能與private、static、final或native並列修飾同一個方法。
介面
何為介面?
可以說是比抽象類還抽象的抽象類,哈哈哈,是不是突然覺得很繞,這裡只是做個描述而已。
準確來說,介面有了更多的約束,JDK7以前介面中的方法必須都是抽象的,並且沒有沒有具體的實現,而JDK8後接口可以使用default、static方法
default方法
簡單的說,就是可以在介面中定義一個已經實現的方法,並且該介面的實現類不需要實現該方法
為什麼要有預設方法?
因為在之前的開發中,我們介面一旦新增或刪除了一個方法,那麼所有實現該介面的類都需要進行修改,如果這個介面被很多類實現了,那麼修改起來還是很麻煩的。所以Java8為了更好的擴充套件性,假如我們要在介面中新增一個方法,那麼只需要使用default實現一個預設方法,不用對實現類進行修改,並且實現類都會繼承這個default方法。
參考了網上的資料,在Java8的Iterable介面中,我們新增了一個預設方法forEach,因為這是default修飾的預設方法,所以不用修改所有實現了Iterable介面的類
default void forEach(Consumer<? super T> action) { //入參是函式式介面,支援Lambda表示式
Objects.requireNonNull(action);
for (T t : this) {
action.accept(t);
}
}
因為Collection介面繼承了Iterable介面,所以Collection具有了forEach方法
List<String> list = new ArrayList<String>();
list.add("001");
list.add("002");
list.forEach(System.out::println);
可見,我們在未破壞Iterable介面實現類的前提下,給Iterable介面的所有實現類添加了一個新方法forEach,這在Java 8之前是不可能的。
重寫Override方法
如果介面實現的類沒有重寫介面的預設方法,那麼預設繼承了介面中的預設實現。
如果介面實現類重寫了介面中的預設方法,那麼與普通的重寫沒有區別。
如果子類(介面或抽象類)重寫父介面的預設方法是抽象方法,那麼子類的子類都需要實現這個方法。
預設方法呼叫衝突問題
因為一個類是可以實現多個介面的,那麼如果多個介面都定義了一樣的預設方法,我們實現的時候該如何呼叫父類的預設方法呢?
- 首先,如果子類覆蓋了父類的預設方法,那麼直接使用子類覆蓋後的方法e
- 其次,優先選擇呼叫更加具體的預設方法,就是說比如介面1繼承了介面2,那麼介面實現類呼叫預設方法時優先呼叫的是介面2的方法,因為介面2的比介面1的更具體
- 如果實現類同時實現了介面1和介面2,且介面1和介面2有同名的預設方法,那麼實現類呼叫時會編譯報錯,提示定義了重名的介面,快速修復的方法是覆蓋其中的一個即可
interface InterfaceA{
default void test(){
System.out.println("testA~~");
}
}
interface InterfaceB extends InterfaceA{
@Override
default void test(){
System.out.println("testB~~");
}
}
interface InterfaceC extends InterfaceA{
}
class TestClass implements InterfaceB,InterfaceC{
@Override
public void test(){
InterfaceB.super.test();
//InterfaceC.super.test(); 這句會報錯,報錯說明中的意思是介面B中有比C更具體的實現,所以不使用介面C,預設使用介面B
}
}
還要注意一個問題,如果該類實現介面時,還繼承了某個抽象類,該抽象類擁有一個和default簽名一樣的抽象方法,則在該類中必須重寫抽象方法(也是介面中的該default方法)。
抽象類、介面存在同樣的簽名方法,抽象類有實現體但是不是public修飾的
—-> 如果子類沒有去實現,那麼編譯錯誤:抽象介面中的實現不能隱藏介面中的方法;如果子類實現了方法,編譯通過
—->解決辦法:將抽象類中的方法訪問控制符使用public修飾
沒想到一個小小的介面可以引申出這麼多問題,Java還真是深似海啊。。。
static靜態方法
static修飾的方法,只能使用介面名呼叫,介面.xxx來呼叫,所以它不存在呼叫衝突問題,因為編譯器可以區分不同介面的呼叫
public interface JDK8Interface {
// static修飾符定義靜態方法
static void staticMethod() {
System.out.println("介面中的靜態方法");
}
// default修飾符定義預設方法
default void defaultMethod() {
System.out.println("介面中的預設方法");
}
}
public class JDK8InterfaceImpl implements JDK8Interface {
//實現介面後,因為預設方法不是抽象方法,所以可以不重寫,但是如果開發需要,也可以重寫
}
public class Main {
public static void main(String[] args) {
// static方法必須通過介面類呼叫
JDK8Interface.staticMethod();
//default方法必須通過實現類的物件呼叫
new JDK8InterfaceImpl().defaultMethod();
}
}
上面程式碼已經直觀的表示了
抽象類和介面的區別
儘管抽象類和介面之間存在較大的相同點,甚至有時候還可以互換,但這樣並不能彌補他們之間的差異之處。下面將從語法層次和設計層次兩個方面對抽象類和介面進行闡述。
語法層次
在語法層次,java語言對於抽象類和介面分別給出了不同的定義。下面以Demo類來說明他們之間的不同之處。
使用抽象類來實現:
public abstract class Demo {
abstract void method1();
void method2(){
//實現
}
}
使用介面來實現
interface Demo {
void method1();
void method2();
}
抽象類方式中,抽象類可以擁有任意範圍的成員資料,同時也可以擁有自己的非抽象方法,但是介面方式中,它僅能夠有靜態、不能修改的成員資料(但是我們一般是不會在介面中使用成員資料),同時它所有的方法都必須是抽象的。在某種程度上來說,介面是抽象類的特殊化。(java8之後介面中可以有預設實現的方法)
對子類而言,它只能繼承一個抽象類(這是java為了資料安全而考慮的),但是卻可以實現多個介面。
設計層次
上面只是從語法層次和程式設計角度來區分它們之間的關係,這些都是低層次的,要真正使用好抽象類和介面,我們就必須要從較高層次來區分了。只有從設計理念的角度才能看出它們的本質所在。一般來說他們存在如下三個不同點:
-
抽象層次不同。抽象類是對類抽象,而介面是對行為的抽象。抽象類是對整個類整體進行抽象,包括屬性、行為,但是介面卻是對類區域性(行為)進行抽象。
-
跨域不同。抽象類所跨域的是具有相似特點的類,而介面卻可以跨域不同的類。我們知道抽象類是從子類中發現公共部分,然後泛化成抽象類,子類繼承該父類即可,但是介面不同。實現它的子類可以不存在任何關係,共同之處。例如貓、狗可以抽象成一個動物類抽象類,具備叫的方法。鳥、飛機可以實現飛Fly介面,具備飛的行為,這裡我們總不能將鳥、飛機共用一個父類吧!所以說抽象類所體現的是一種繼承關係,要想使得繼承關係合理,父類和派生類之間必須存在**“is-a”**關係,即父類和派生類在概念本質上應該是相同的。對於介面則不然,並不要求介面的實現者和介面定義在概念本質上是一致的, 僅僅是實現了介面定義的契約而已。
-
設計層次不同。對於抽象類而言,它是自下而上來設計的,我們要先知道子類才能抽象出父類,而介面則不同,它根本就不需要知道子類的存在,只需要定義一個規則即可,至於什麼子類、什麼時候怎麼實現它一概不知。比如我們只有一個貓類在這裡,如果你這是就抽象成一個動物類,是不是設計有點兒過度?我們起碼要有兩個動物類,貓、狗在這裡,我們在抽象他們的共同點形成動物抽象類吧!所以說抽象類往往都是通過重構而來的!但是介面就不同,比如說飛,我們根本就不知道會有什麼東西來實現這個飛介面,怎麼實現也不得而知,我們要做的就是事前定義好飛的行為介面。所以說抽象類是自底向上抽象而來的,介面是自頂向下設計出來的。
(上面純屬個人見解,如有出入、錯誤之處,望各位指點!!!!)
我們有一個Door的抽象概念,它具備兩個行為open()和close(),此時我們可以定義通過抽象類和介面來定義這個抽象概念:
抽象類
abstract class Door{
abstract void open();
abstract void close();
}
介面
interface Door{
void open();
void close();
}
至於其他的具體類可以通過使用extends使用抽象類方式定義Door或者Implements使用介面方式定義Door,這裡發現兩者並沒有什麼很大的差異。
但是現在如果我們需要門具有報警的功能,那麼該如何實現呢?
解決方案一:給Door增加一個報警方法:clarm();
abstract class Door{
abstract void open();
abstract void close();
abstract void alarm();
}
或者
interface Door{
void open();
void close();
void alarm();
}
這種方法違反了面向物件設計中的一個核心原則ISP (Interface Segregation Principle - 介面隔離原理)—見批註,在Door的定義中把Door概念本身固有的行為方法和另外一個概念"報警器"的行為方法混在了一起。這樣引起的一個問題是那些僅僅依賴於Door這個概念的模組會因為"報警器"這個概念的改變而改變,反之依然。
解決方案二
既然open()、close()和alarm()屬於兩個不同的概念,那麼我們依據ISP原則將它們分開定義在兩個代表兩個不同概念的抽象類裡面,定義的方式有三種:
-
兩個都使用抽象類來定義。
-
兩個都使用介面來定義。
-
一個使用抽象類定義,一個是用介面定義。
由於java不支援多繼承所以第一種是不可行的。後面兩種都是可行的,但是選擇何種就反映了你對問題域本質的理解。
如果選擇第二種都是介面來定義,那麼就反映了兩個問題:1、我們可能沒有理解清楚問題域,AlarmDoor在概念本質上到底是門還報警器。2、如果我們對問題域的理解沒有問題,比如我們在分析時確定了AlarmDoor在本質上概念是一致的,那麼我們在設計時就沒有正確的反映出我們的設計意圖。因為你使用了兩個介面來進行定義,他們概念的定義並不能夠反映上述含義。
第三種,如果我們對問題域的理解是這樣的:AlarmDoor本質上Door,但同時它也擁有報警的行為功能,這個時候我們使用第三種方案恰好可以闡述我們的設計意圖。AlarmDoor本質是門,所以對於這個概念我們使用抽象類來定義,同時AlarmDoor具備報警功能,說明它能夠完成報警概念中定義的行為功能,所以alarm可以使用介面來進行定義。如下:
abstract class Door{
abstract void open();
abstract void close();
}
interface Alarm{
void alarm();
}
class AlarmDoor extends Door implements Alarm{
void open(){}
void close(){}
void alarm(){}
}
這種實現方式基本上能夠明確的反映出我們對於問題領域的理解,正確的揭示我們的設計意圖。其實抽象類表示的是"is-a"關係,介面表示的是"like-a"關係,大家在選擇時可以作為一個依據,當然這是建立在對問題領域的理解上的,比如:如果我們認為AlarmDoor在概念本質上是報警器,同時又具有Door的功能,那麼上述的定義方式就要反過來了。
批註: ISP(Interface Segregation Principle):面向物件的一個核心原則。它表明使用多個專門的介面比使用單一的總介面要好。 一個類對另外一個類的依賴性應當是建立在最小的介面上的。 一個介面代表一個角色,不應當將不同的角色都交給一個介面。沒有關係的介面合併在一起,形成一個臃腫的大介面,這是對角色和介面的汙染。
總結
-
抽象類在java語言中所表示的是一種繼承關係,一個子類只能存在一個父類,但是可以存在多個介面。
-
在抽象類中可以擁有自己的成員變數和非抽象類方法,但是介面中只能存在靜態的不可變的成員資料(不過一般都不在介面中定義成員資料),而且它的所有方法都是抽象的。
-
抽象類和介面所反映的設計理念是不同的,抽象類所代表的是“is-a”的關係,而介面所代表的是“like-a”的關係。
抽象類和介面是Java語言中兩種不同的抽象概念,他們的存在對多型提供了非常好的支援,雖然他們之間存在很大的相似性。但是對於他們的選擇往往反應了您對問題域的理解。只有對問題域的本質有良好的理解,才能做出正確、合理的設計。
面向介面程式設計
面向介面程式設計實現了**“高內聚、低耦合”**
內聚性又稱塊內聯絡。指單個模組的功能強度的度量,即一個模組內部各個元素彼此結合的緊密程度的度量。若一個模組內各元素(語名之間、程式段之間)聯絡的越緊密,則它的內聚性就越高。 高內聚就是在一個模組內,讓每個元素之間都儘可能的緊密相連。也就是充分利用每一個元素的功能,各施所能,以最終實現某個功能。如果某個元素與該模組的關係比較疏鬆的話,可能該模組的結構還不夠完善,或者是該元素是多餘的。
最充分的利用模組中每一個元素的功能,達到功能實現最大化,內聚性越強越好,用最小的資源幹最大的事情
耦合性也稱塊間聯絡。指軟體系統結構中各模組間相互聯絡緊密程度的一種度量。模組之間聯絡越緊密,其耦合性就越強,模組的獨立性則越差。模組間耦合高低取決於模組間介面的複雜性、呼叫的方式及傳遞的資訊。 專案中的各個模組之間的關聯要儘可能的小,耦合性(相互間的聯絡)越低越好,減小“牽一髮而動全身”的可能性
內聚和耦合,需要儘量實現功能的內聚和資料的耦合,在縱向和橫向都要實現優化,縱向主要是對各個層的內聚和耦合,橫向主要是對一個層上的各個模組和類之間的耦合。
並且面向介面程式設計也符合多項面向物件的設計原則,單一職責原則告訴我們實現類要職責單一;里氏替換原則告訴我們不要破壞繼承體系;依賴倒置原則告訴我們要面向介面程式設計;介面隔離原則告訴我們在設計介面的時候要精簡單一;迪米特法則告訴我們要降低耦合。而開閉原則是總綱,他告訴我們要對擴充套件開放,對修改關閉。