一.封裝、抽象、繼承、多態分別可以解決哪些編程問題?
封裝
- 封裝也叫作信息隱藏或者數據訪問保護。類通過暴露有限的訪問接口,授權外部僅能通過類提供的方式(或者叫函數)來訪問內部信息或者數據
抽象
- 封裝主要講的是如何隱藏信息、保護數據,而抽象講的是如何隱藏方法的具體實現
- 抽象這個概念是一個非常通用的設計思想,並不單單用在面向對象編程中,也可以用來指導架構設計等。而且這個特性也並不需要編程語言提供特殊的語法機制來支持,只需要提供“函數”這一非常基礎的語法機制,就可以實現抽象特性、所以,它沒有很強的“特異性”,有時候並不被看作面向對象編程的特性之一
繼承
- 繼承最大的一個好處就是代碼複用
多態
- 多態。多態是指,子類可以替換父類
- 利用繼承實現多態
- 利用接口實現多態
- 多態特性能提高代碼的可擴展性和複用性:僅用一個 print() 函數就可以實現遍歷打印不同類型(Array、LinkedList)集合的數據
二.面向對象相比面向過程有哪些優勢?面向過程真的過時了嗎?
面向過程編程與面向過程編程語言
- 面向過程編程
面向過程編程也是一種編程範式或編程風格。它以過程(可以理解爲方法、函數、操作)作爲組織代碼的基本單元,以數據(可以理解爲成員變量、屬性)與方法相分離爲最主要的特點。面向過程風格是一種流程化的編程風格,通過拼接一組順序執行的方法來操作數據完成一項功能。
- 面向對象與面向過程區別例子:
例:假設我們有一個記錄了用戶信息的文本文件 users.txt,每行文本的格式是 name&age&gender(比如,小王 &28& 男)。我們希望寫一個程序,從 users.txt 文件中逐行讀取用戶信息,然後格式化成 name\tage\tgender(其中,\t 是分隔符)這種文本格式,並且按照 age 從小到大排序之後,重新寫入到另一個文本文件 formatted_users.txt 中
- 面向過程實現:
struct User {
char name[64];
int age;
char gender[16];
};
struct User parse_to_user(char* text) {
// 將text(“小王&28&男”)解析成結構體struct User
}
char* format_to_text(struct User user) {
// 將結構體struct User格式化成文本("小王\t28\t男")
}
void sort_users_by_age(struct User users[]) {
// 按照年齡從小到大排序users
}
void format_user_file(char* origin_file_path, char* new_file_path) {
// open files...
struct User users[1024]; // 假設最大1024個用戶
int count = 0;
while(1) { // read until the file is empty
struct User user = parse_to_user(line);
users[count++] = user;
}
sort_users_by_age(users);
for (int i = 0; i < count; ++i) {
char* formatted_user_text = format_to_text(users[i]);
// write to new file...
}
// close files...
}
int main(char** args, int argv) {
format_user_file("/home/zheng/user.txt", "/home/zheng/formatted_users.txt");
}
- 面向對象實現:
public class User {
private String name;
private int age;
private String gender;
public User(String name, int age, String gender) {
this.name = name;
this.age = age;
this.gender = gender;
}
public static User praseFrom(String userInfoText) {
// 將text(“小王&28&男”)解析成類User
}
public String formatToText() {
// 將類User格式化成文本("小王\t28\t男")
}
}
public class UserFileFormatter {
public void format(String userFile, String formattedUserFile) {
// Open files...
List users = new ArrayList<>();
while (1) { // read until file is empty
// read from file into userText...
User user = User.parseFrom(userText);
users.add(user);
}
// sort users by age...
for (int i = 0; i < users.size(); ++i) {
String formattedUserText = user.formatToText();
// write to new file...
}
// close files...
}
}
public class MainApplication {
public static void main(String[] args) {
UserFileFormatter userFileFormatter = new UserFileFormatter();
userFileFormatter.format("/home/zheng/users.txt", "/home/zheng/formatted_users.txt");
}
}
- 區別: 面向過程風格的代碼被組織成了一組方法集合及其數據結構(struct User),方法和數據結構的定義是分開的。面向對象風格的代碼被組織成一組類,方法和數據結構被綁定一起,定義在類中。
面向對象編程相比面向過程編程有哪些優勢?
- 對於大規模複雜程序的開發,程序的處理流程並非單一的一條主線,而是錯綜複雜的網狀結構。面向對象編程比起面向過程編程,更能應對這種複雜類型的程序開發。
- 面向對象編程相比面向過程編程,具有更加豐富的特性(封裝、抽象、繼承、多態)。利用這些特性編寫出來的代碼,更加易擴展、易複用、易維護。
- 從編程語言跟機器打交道的方式的演進規律中,我們可以總結出:面向對象編程語言比起面向過程編程語言,更加人性化、更加高級、更加智能。
三.哪些代碼設計看似是面向對象,實際是面向過程的?
濫用 getter、setter 方法
-
它違反了面向對象編程的封裝特性,相當於將面向對象編程風格退化成了面向過程編程風格。數據沒有訪問權限控制,任何代碼都可以隨意修改它,代碼就退化成了面向過程編程風格的了
-
處理:
在設計實現類的時候,除非真的需要,否則,儘量不要給屬性定義 setter 方法。除此之外,儘管 getter 方法相對 setter 方法要安全些,但是如果返回的是集合容器(比如例子中的 List 容器),也要防範集合內部數據被修改的危險。
濫用全局變量和全局方法
- 只包含靜態方法不包含任何屬性的 Utils 類,是徹徹底底的面向過程的編程風格。
- 即便在面向對象編程中,我們也並不是完全排斥面向過程風格的代碼。只要它能爲我們寫出好的代碼貢獻力量,我們就可以適度地去使用。
- 除此之外,類比 Constants 類的設計,我們設計 Utils 類的時候,最好也能細化一下,針對不同的功能,設計不同的 Utils 類,比如 FileUtils、IOUtils、StringUtils、UrlUtils 等,不要設計一個過於大而全的 Utils 類。
定義數據和方法分離的類
- 所有操作這些數據的業務邏輯都定義在對應的 Controller 類、Service 類、Repository 類中。這就是典型的面向過程的編程風格。
四.接口vs抽象類的區別?如何用普通的類模擬抽象類和接口?
抽象類
- 抽象類特性
- 抽象類不允許被實例化,只能被繼承。也就是說,你不能 new 一個抽象類的對象出來(Logger logger = new Logger(…); 會報編譯錯誤)。
- 抽象類可以包含屬性和方法。方法既可以包含代碼實現(比如 Logger 中的 log() 方法),也可以不包含代碼實現(比如 Logger 中的 doLog() 方法)。不包含代碼實現的方法叫作抽象方法。
- 子類繼承抽象類,必須實現抽象類中的所有抽象方法。對應到例子代碼中就是,所有繼承 Logger 抽象類的子類,都必須重寫 doLog() 方法。
- 代碼複用,不可實例化,強制實現某種方法
接口
- 接口特性
- 接口不能包含屬性(也就是成員變量)
- 接口只能聲明方法,方法不能包含代碼實現。
- 類實現接口的時候,必須實現接口中聲明的所有方法
- 抽象類更多的是爲了代碼複用,而接口就更側重於解耦,提高代碼的可擴展性。
如何決定該用抽象類還是接口?
- 實際上,判斷的標準很簡單。如果我們要表示一種 is-a 的關係,並且是爲了解決代碼複用的問題,我們就用抽象類;如果我們要表示一種 has-a 關係,並且是爲了解決抽象而非代碼複用的問題,那我們就可以使用接口。
五.爲什麼基於接口而非實現編程?有必要爲每個類都定義接口嗎?
- 接口意識、抽象思維
- 在編寫代碼的時候,要遵從“基於接口而非實現編程”的原則
- 函數的命名不能暴露任何實現細節。比如,前面提到的 uploadToAliyun() 就不符合要求,應該改爲去掉 aliyun 這樣的字眼,改爲更加抽象的命名方式,比如:upload()。
- 封裝具體的實現細節。比如,跟阿里雲相關的特殊上傳(或下載)流程不應該暴露給調用者。我們對上傳(或下載)流程進行封裝,對外提供一個包裹所有上傳(或下載)細節的方法,給調用者使用。
- 爲實現類定義抽象的接口。具體的實現類都依賴統一的接口定義,遵從一致的上傳功能協議。使用者依賴接口,而不是具體的實現類來編程
3.例子:上傳下載(重構前)
public class AliyunImageStore {
//...省略屬性、構造函數等...
public void createBucketIfNotExisting(String bucketName) {
// ...創建bucket代碼邏輯...
// ...失敗會拋出異常..
}
public String generateAccessToken() {
// ...根據accesskey/secrectkey等生成access token
}
public String uploadToAliyun(Image image, String bucketName, String accessToken) {
//...上傳圖片到阿里雲...
//...返回圖片存儲在阿里雲上的地址(url)...
}
public Image downloadFromAliyun(String url, String accessToken) {
//...從阿里雲下載圖片...
}
}
// AliyunImageStore類的使用舉例
public class ImageProcessingJob {
private static final String BUCKET_NAME = "ai_images_bucket";
//...省略其他無關代碼...
public void process() {
Image image = ...; //處理圖片,並封裝爲Image對象
AliyunImageStore imageStore = new AliyunImageStore(/*省略參數*/);
imageStore.createBucketIfNotExisting(BUCKET_NAME);
String accessToken = imageStore.generateAccessToken();
imagestore.uploadToAliyun(image, BUCKET_NAME, accessToken);
}
}
問題:假如修改上傳方式
- 重構後
public interface ImageStore {
String upload(Image image, String bucketName);
Image download(String url);
}
public class AliyunImageStore implements ImageStore {
//...省略屬性、構造函數等...
public String upload(Image image, String bucketName) {
createBucketIfNotExisting(bucketName);
String accessToken = generateAccessToken();
//...上傳圖片到阿里雲...
//...返回圖片在阿里雲上的地址(url)...
}
public Image download(String url) {
String accessToken = generateAccessToken();
//...從阿里雲下載圖片...
}
private void createBucketIfNotExisting(String bucketName) {
// ...創建bucket...
// ...失敗會拋出異常..
}
private String generateAccessToken() {
// ...根據accesskey/secrectkey等生成access token
}
}
// 上傳下載流程改變:私有云不需要支持access token
public class PrivateImageStore implements ImageStore {
public String upload(Image image, String bucketName) {
createBucketIfNotExisting(bucketName);
//...上傳圖片到私有云...
//...返回圖片的url...
}
public Image download(String url) {
//...從私有云下載圖片...
}
private void createBucketIfNotExisting(String bucketName) {
// ...創建bucket...
// ...失敗會拋出異常..
}
}
// ImageStore的使用舉例
public class ImageProcessingJob {
private static final String BUCKET_NAME = "ai_images_bucket";
//...省略其他無關代碼...
public void process() {
Image image = ...;//處理圖片,並封裝爲Image對象
ImageStore imageStore = new PrivateImageStore(...);
imagestore.upload(image, BUCKET_NAME);
}
}
總結一下,我們在做軟件開發的時候,一定要有抽象意識、封裝意識、接口意識。在定義接口的時候,不要暴露任何實現細節。接口的定義只表明做什麼,而不是怎麼做。而且,在設計接口的時候,我們要多思考一下,這樣的接口設計是否足夠通用,是否能夠做到在替換具體的接口實現的時候,不需要任何接口定義的改動。
是否需要爲每個類定義接口?
- 前面我們也提到,這條原則的設計初衷是,將接口和實現相分離,封裝不穩定的實現,暴露穩定的接口。上游系統面向接口而非實現編程,不依賴不穩定的實現細節,這樣當實現發生變化的時候,上游系統的代碼基本上不需要做改動,以此來降低代碼間的耦合性,提高代碼的擴展性
- 從這個設計初衷上來看,如果在我們的業務場景中,某個功能只有一種實現方式,未來也不可能被其他實現方式替換,那我們就沒有必要爲其設計接口,也沒有必要基於接口編程,直接使用實現類就可以了。
六. 爲何說要多用組合少用繼承?如何決定該用組合還是繼承?
爲什麼不推薦使用繼承?
繼承最大的問題就在於:繼承層次過深、繼承關係過於複雜會影響到代碼的可讀性和可維護性。
組合相比繼承有哪些優勢?
- 接口表示具有某種行爲特性。針對“會飛”這樣一個行爲特性,我們可以定義一個 Flyable 接口,只讓會飛的鳥去實現這個接口。對於會叫、會下蛋這些行爲特性,我們可以類似地定義 Tweetable 接口、EggLayable 接口
- 例如:
public interface Flyable {
void fly();
}
public interface Tweetable {
void tweet();
}
public interface EggLayable {
void layEgg();
}
public class Ostrich implements Tweetable, EggLayable {//鴕鳥
//... 省略其他屬性和方法...
@Override
public void tweet() { //... }
@Override
public void layEgg() { //... }
}
public class Sparrow impelents Flayable, Tweetable, EggLayable {//麻雀
//... 省略其他屬性和方法...
@Override
public void fly() { //... }
@Override
public void tweet() { //... }
@Override
public void layEgg() { //... }
}
- 問題:接口只聲明方法,不定義實現。也就是說,每個會下蛋的鳥都要實現一遍 layEgg() 方法,並且實現邏輯是一樣的,這就會導致代碼重複的問題。那這個問題又該如何解決呢?
- 們可以針對三個接口再定義三個實現類,它們分別是:實現了 fly() 方法的 FlyAbility 類、實現了 tweet() 方法的 TweetAbility 類、實現了 layEgg() 方法的 EggLayAbility 類。然後,通過組合和委託技術來消除代碼重複
- 例子:
public interface Flyable {
void fly();
}
public class FlyAbility implements Flyable {
@Override
public void fly() { //... }
}
//省略Tweetable/TweetAbility/EggLayable/EggLayAbility
public class Ostrich implements Tweetable, EggLayable {//鴕鳥
private TweetAbility tweetAbility = new TweetAbility(); //組合
private EggLayAbility eggLayAbility = new EggLayAbility(); //組合
//... 省略其他屬性和方法...
@Override
public void tweet() {
tweetAbility.tweet(); // 委託
}
@Override
public void layEgg() {
eggLayAbility.layEgg(); // 委託
}
}
如何判斷該用組合還是繼承
- 如果類之間的繼承結構穩定(不會輕易改變),繼承層次比較淺(比如,最多有兩層繼承關係),繼承關係不復雜,我們就可以大膽地使用繼承。反之,系統越不穩定,繼承層次很深,繼承關係複雜,我們就儘量使用組合來替代繼承