博客鏈接:https://www.bughui.com/2017/08/21/how-to-write-code/ 作者:Bug輝
寫代碼就是學一門語言然後開始擼代碼嗎?看完了我的《GoF設計模式》系列文章的同學或者本身已經就是老鳥的同學顯然不會這麼認爲。編程是一項非常嚴謹的工作!雖然我們自嘲爲碼農,但是這工作畢竟不是真正的搬磚,我們是軟件工程師。編程需要關注的問題太多,不僅僅有語言,還有算法、數據結構、編程技巧、編碼風格、設計、架構、工程化、開發工具、團隊協作等方方面面,涉及到很多層面的問題。本文將分享一下根據我這幾年來的編程經驗總結出的一些關於如何寫代碼的個人見解。
由於“跟我混”的一些小夥伴編程功底相對來說比較薄弱,所以在此總結一篇“編程內功心法”幫助他們渡過職業生涯的第一個瓶頸期。順便,也造福一下路過的有緣的同學!於是有了此文。
前言
首先,思考一個問題,何謂編程?編程就是寫代碼嗎?
所謂的編程,其實就是不斷的對這個現實世界中的問題建立模型並將其固化爲代碼自動化執行的過程。
~ Bug輝 《GoF設計模式 - 解釋器模式》
在對問題建立模型的過程中,我們會遇到非常多不同層面的問題,所以我們需要很多領域的知識去解決這些問題。
- 我們需要管理被操作的數據,因爲數據與數據之前是相互有關聯的。將數據結構化,通常是編程的第一步。關於結構化數據的相關理論以及實踐,需要有一個專門的學科分支或者說課題去研究——數據結構。
- 我們需要解決一個具體的問題,這個具體的問題如何一步步去解決,過程是怎麼樣子的——算法。
- 我們需要將解決方案進行自動化,並以代碼的形式進行交付——編程語言。
- 如果將一個抽象的模型進行編碼實現,如何實現“這個功能”,如何實現“那個功能”——編程技巧。
- 問題的規模大了,衆多代碼糅合在一起,連程序員自己都看不懂了!怎麼來拆分、模塊化這些代碼——設計。
- 代碼量已經到了一個人無法完成的地步了,需要團隊分工合作才能完成了——工程化。
- 你寫的代碼我看不懂,沒法調用或者很難調用,我寫的代碼你也看不懂,或者很難看懂。還怎麼愉快的玩耍——編碼風格/編碼規範。
- 問題的規模繼續擴大,到了系統工程的規模了,之前學的套路已經不管用了!怎麼來構建這個系統才能實現正確、安全、高性能、高可用——架構。
然而這些也只是一個系統工程中的冰山一角!這是一個龐大的體系。也正是因爲軟件開發需要考慮到的問題太多且團隊成員水品參差不齊,所以團隊開發中並不是每個程序員做的事情都是一樣的。每個人都有自己的角色、初級工程師、中級工程師、高級工程師、架構師、CTO。。。
所以編程不僅僅只是堆砌代碼!
說到這裏,我想起來了一件事情——爲啥業界普遍鄙視培訓出來半道出家的新人?人與人的區別是很大的!我見過培訓出來也很牛的。其實,說到底,被鄙視的並不是所有人。而是那些培訓了幾個月之後發現隨便找個工作也能拿“高薪”然後還自認爲編程很簡單的新人。因爲這種經歷給了他們一種錯覺——編程如此簡單,我培訓幾個月也會嘛!有點像剛學會開車的新司機,很囂張的對老司機說“開車很簡單嘛!你看我也會啊!”。語言和開發工具只是招式,這是外功。而編程思想、經驗是內功。這些內功並不是靠短短幾個月的培訓能夠掌握的,這一點有點像中國製造業和日本製造業的區別。動不動趕英超美可不好。。。
編程並不簡單!這是一件很嚴肅的事情。不過今天,我沒有辦法介紹完所有的方面!或者說,到今天爲止,我也並沒能掌握所有領域的知識。所以今天我只是分享一些關於編碼本身的一些經驗。
另外,本文主要分享如何寫代碼,並不是如何用Java寫代碼。所以文章中各種語言都有可能出現。
編碼風格
先來一個圈內的段子。
大部分程序員在工作中都很討厭這四件事情:
- 寫註釋
- 寫文檔
- 別人不寫註釋
- 別人不寫文檔
o(∩_∩)o 哈哈。。中槍了沒!
這個段子其實反映出來一個問題,即大部分代碼都需要通過大量註釋和文檔來說明才能將意圖傳達給維護這些代碼的程序員!然而,就像上面的段子說的那樣,寫大量的註釋和文檔其實是一件很麻煩的事情。所以很多時候,由於嫌麻煩,註釋和文檔就沒寫,導致維護代碼的人相當的痛苦。這個苦同學們肯定都是體會過的!相當於給你個精密儀器要你維護還不給說明書。
其實,打破上面那個段子描述的那個怪圈的一個很有效的手段就是統一編碼風格。優秀的代碼可以實現代碼即註釋,代碼本身就可以非常清晰的體現出它的意圖來,讓別人可以很容易讀懂。這就是所謂的可讀性!
命名
計算機科學領域中最難的兩件事是命名和緩存失效!命名並不簡單,很複雜。好的名字可以見名知意,非常容易理解。之所以說命名難是因爲命名的過程同時也是概念提取的過程!對問題建立模型,需要提取概念並賦予其“術語”。這個過程其實是“萬里長征”中最難的一步。畢竟,設計也好,架構也罷,都有成熟的套路可以參考。唯獨這個過程,是需要程序設計者自己進行充分的思考的創造性工作!
以下是總結出來的一些命名經驗:
- 一個類是某物、某事、某人的抽象,是數據與行爲的集合體。這恰好符合名詞的定義,因此 類名 是一個名詞!
- 方法名 或者說 函數名 是某操作或者某過程的抽象,是一個動作。這恰好符合動詞的定義,因此函數名通常是一個動詞。
- 變量名寧可長一些說明清楚用途也不要用
a
、b
、c
之類的無意義的名稱,除非是循環計數器中用i
、j
、k
等約定俗成的一些變量名。比如pageIndex
和pageSize
就要比取名成i
和s
好!取成這種和用混淆器混淆過後的代碼一樣的名稱沒有什麼好處,如果算法比較複雜的話,過一段時間恐怕自己都會看不懂。 - 變量名最好包含變量本身的業務含義。比如
List<Student> studentList = new ArrayList<>();
就比List<Student> list = new ArrayList<>();
好很多。如果同一段代碼裏再出現一個List<Teacher>
的話,這樣就可以很方便的取名爲teacherList
或者teachers
而不是list1
和list2
這樣的毫無意義的名稱!
英文不好怎麼辦
這個問題怎麼說呢。。
作爲一名程序員吧,基礎的英文還是要懂的。要不然發展也容易遇到天花板,學不好編程的。畢竟,最新的技術、解決方案、工具都是從國外傳過來的。如果是解決一些基礎性的問題,每天只做做CRUD,好像英文確實不怎麼用得上。但是一旦遇到一些實質性問題,恐怕只能到英文網站上找嘍!ㄟ(▔ ,▔)ㄏ 不要跟我說你編程可以不需要Stack Overflow。copying
and pasting from stackoverflow 可是終極編程大法!o(∩_∩)o 這句話可是編程的真諦啊!(如果你看不懂這個梗那你有可能是僞程序員)
其實,話說回來,實在不方便用英文的時候,我認爲也可以用拼音命名。這個問題上可以務實一點,量力而行。但是,拼音和英語混用的做法就不太好了。最好別這樣!逼格不高。
註釋
怎麼添加代碼註釋
關於註釋,我們需要解決的第一個問題是如何添加代碼註釋。
對於Java、C#之類的語言,有專用的文檔註釋語法,很好處理。對於C/C++,則按約定的格式說明一下類和函數、代碼片段的作用和意圖即可,至少編譯器會進行靜態檢查。在Python中,有更牛逼的文檔字符串這樣的語言級特性支持,看註釋用help()
很方便。不過對於Lua這樣的弱類型解釋型語言,註釋就比較難處理了。這裏以Lua爲例給出一種註釋的解決方案。
借用Java語言文檔註釋的風格。
文件註釋,或者說類/模塊註釋。
--[[
Object-oriented helper functions for Lua
@author: Elvin Zeng
@date: 2017-8-21
--]]
函數註釋
--[[
create a class with specified super class.
if number of parameters is zero, derived class will extends from {}.
@param superClass super class of target class
@return derived class
--]]
local function createClass(superClass)
local derivedClass = {}
-- 省略一堆代碼
return derivedClass
end
--[[
register a new account
@param user
{
username = "username",
password = "password"
}
@return registered user
--]]
local function register(user)
end
tips: Lua中可以通過metatable機制實現類和繼承,這一點與Javascript通過原型機制來實現類和繼承有點類似。
註釋裏該寫些什麼
我們首先來看個反例。
/**
* 查詢
*/
public List<Article> queryPage(int pageSize, int pageIndex) throws PageIndexOutOfBoundsException {
// 定義一個整型變量
int offset;
// 省略一堆代碼
}
首先這個方法名本身就取得不好,這個暫且不說,先說註釋問題。這裏的註釋犯了幾個錯:
- 方法註釋爲“查詢”,這簡直就是廢話!方法名已經告訴別人這是查詢方法了,還在這個註釋裏寫這兩個字有什麼意義呢?而且到底查詢些什麼這裏也沒說!
- 參數沒有註釋。沒有描述每一個參數的意義以及取值範圍等!
- 什麼情況下會拋出
PageIndexOutOfBoundsException
沒有描述清楚。 - “定義一個整型變量”這種垃圾註釋就不要寫了,這麼簡單的語句誰看不懂啊!如果要註釋,也是寫上這個變量的含義。
這裏我們先不考慮設計問題(分頁索引號最好做成可以自己調整成合理值),下面再來看改善註釋之後的代碼。
/**
* 列出指定分頁的文章
* @param pageSize 分頁大小。如果等於0則表示查詢出所有文章。
* @param pageIndex 分頁索引號。必須爲一個大於0的整數,第一頁索引爲1。
* @return 指定分頁的文章列表
* @throws PageIndexOutOfBoundsException 當分頁索引號超出正常範圍時拋出,即pageIndex小於0或大於最大頁索引時。
*/
public List<Article> listArticle(int pageSize, int pageIndex) throws PageIndexOutOfBoundsException{
// 第一條文章記錄在MySql數據庫中的偏移量
int offset;
// 省略一堆代碼
}
改完之後的註釋有沒有感覺信息更全很多!雖然說代碼本身就是最好的註釋,但是必要的註釋還是得寫上去,畢竟調用的時候別人沒法猜測你的索引號到底從0還是從1開始。另外,如果函數內算法比較複雜,可以在代碼塊內註釋,也可以在函數註釋上直接寫清楚這個函數內部的大概算法/邏輯。代碼寫出來就是給別人調用的,如果沒有基本的註釋信息,那麼每次調用你的代碼的時候,都得去看一下你的函數內具體邏輯才能知道怎麼調用。這顯然是非常低效的!
命名與註釋這兩個基本方面沒做好的話,會影響到整個團隊的運作。也就是說,你封裝的東西並沒有給隊友節省什麼時間,別人用到你的代碼的時候,又需要花上一些時間去讀你的代碼。如果團隊裏每個人都這樣,那整個團隊都會極其低效。我個人是非常不願意與這種代碼風格惡劣的人合作的。
參考規範
關於編碼風格的問題,本文只說命名和註釋這兩個方面。關於縮進、空格、斷行、空行等其他方面的問題,可以參考本節給出的參考規範。
不同的企業會有不同的編碼規範,所以這裏沒有辦法給出一個符合所有公司的規範。不過制定自己團隊的規範的時候,可以參考一些大企業的做法。以下是世界上最大的互聯網公司谷歌的編碼規範,同學們可以參考這個。
- Google Java Style Guide
- Google C++ Style Guide
- Google Python Style Guide
- Google HTML/CSS Style Guide
- Google JavaScript Style Guide
異常處理
異常與返回值有什麼不同
在C語言中,我們的函數通常會返回一個整型值作爲狀態碼用於通知客戶端調用的結果。比如0表示成功,非0表示失敗。並且可以通過不同的數值來表示不同原因導致的失敗。然而在Java、C#、C++一類面嚮對象語言中,一般不會用返回值來表示狀態。返回值一般用於表示返回的業務值,而異常用於通知客戶端程序運行狀態改變了。
什麼時候需要拋出異常
關於這個問題,我想到了一句極其精煉的話:當函數無法完成其宣稱的任務的時候拋出異常!
比如上面的那個日子,當listArticle
方法由於種種原因無法查詢出文章列表的時候,則拋出異常。
拋出異常在這種場景下是非常有必要的,因爲這樣其他人調用你的代碼時可以非常放心的去調用,只要調用了你的方法,就會返回文章列表。如果無法返回文章列表,則會拋出異常。完全不用在調用這個函數的時候去懷疑是否執行成功了。
再來一句至理名言:
寧願終止程序也不要帶錯運行下去。
也就是說,遇到錯誤的時候,寧願拋出異常終止程序,也不要帶着錯運行下去。這是在掩耳盜鈴!
異常需要攜帶什麼信息
首先,異常的類型本身會帶有異常種類信息。其次,異常的message
屬性可以帶上更詳細一些的信息。這裏需要注意,千萬不要像下面這麼做。
throw new PageIndexOutOfBoundsException("失敗!");
拋出異常了肯定是執行失敗了呀!帶上這種信息有什麼用,不是帶了一句廢話嘛!
應該是下面這樣
throw new PageIndexOutOfBoundsException("參數分頁索引號pageIndex不能大於分頁總數");
此外,異常堆棧也會攜帶很多信息。
日誌
談到日誌,首先要搞清楚一個問題,日誌是幹嘛用的?
用來記錄運行時的錯誤信息啊!
是啊。好像大家都知道日誌是幹什麼用的,但是爲什麼寫起代碼來就會忘記初衷呢!
來看看代碼:
/**
* 異步發送通知郵件。
* @param templateFile 郵件模板文件路徑,相對於classpath。
* @param modelMap 模型對象
*/
public void sendEmail(String templateFile, Map<String, String> modelMap){
// 這裏省略一些代碼
System.out.println("1");
// 這裏省略一些代碼
System.out.println("2");
// 這裏省略一些代碼
}
這裏的代碼是什麼意思呢?程序員們應該都能明白的!很顯然,這位程序員是想借助這些標記來調試,想知道代碼到底執行到哪一行了。但是,這裏很明顯地犯了兩個錯。
- 爲什麼是
System.out.println("");
而不是logger.debug("");
? - 爲什麼是
1
、2
而不是一些更明確的文字信息呢?
在這裏,合理的方式是下面這樣。
/**
* 異步發送通知郵件。
* @param templateFile 郵件模板文件路徑,相對於classpath。
* @param modelMap 模型對象
* @throws ServiceException 當郵件模板文件不存在或者modelMap中缺少必須的字段。
*/
public void sendEmail(String templateFile, Map<String, String> modelMap) throws ServiceException{
// 這裏省略一些代碼
if (isTemplateExists){
logger.debug("模板文件存在");
// 這裏省略一些代碼
logger.debug("郵件發送任務成功入隊。任務id:" + taskId);
// 這裏省略一些代碼
}else{
logger.error("指定的模板文件[" + templateFile + "]不存在,郵件發送失敗。");
// 拋出異常
}
}
我想給正在犯上面的錯的同學提個醒:
-
使用日誌框架,並用合適的級別輸出日誌非常重要。
好多程序員從來不負責也不參與運維相關的工作,甚至是做了好幾年的Web都從來沒有自己發佈過網站。所以壓根沒有後期維護的意識!
如果沒有這些日誌,當項目上線之後,運維的背鍋俠兄弟發現網站掛了之後只能直接重啓,然後當作什麼也沒看到。因爲沒有排錯的線索。 -
輸出有效信息。
不要去輸出一些像1
、2
、3
、成功
、失敗
、hello
這樣的毫無意義的日誌,要輸出logger.debug("郵件發送任務成功入隊。任務id:" + taskId);
這樣的有效信息。
也許當時你調試的時候,在你看來這些奇怪的字符串是有意義的,但是在其他人看來,這些就是天書。運維的背鍋俠會提刀過來砍你的!另外像"-------開始執行--------"
這種對運行期間定位問題沒有半點好處的日誌就不要輸出了!自己用可以,提交代碼前一定要刪掉。 -
日誌中帶上上下文信息。
孤立的一句錯誤日誌通常沒有什麼實際作用。比如上面的例子中,如果在找不到指定的模板文件的時候未將發送郵件時指定的模板文件名輸出,那麼排錯的時候無法知道到底是少了哪個模板文件。 -
不要在日誌中輸出用戶的敏感信息。
千萬不要在日誌中輸出像用戶密碼、郵件內容之類的涉及用戶隱私的敏感信息,也不要去輸出像驗證碼的值之類的敏感信息。
參數校驗
在你對外公開的方法前先插入一些檢查參數的代碼,以確保方法被“正確的姿勢”調用。比如:
/**
* 列出指定分頁的文章
* @param pageSize 分頁大小。如果等於0則表示查詢出所有文章。
* @param pageIndex 分頁索引號。必須爲一個大於0的整數,第一頁索引爲1。
* @return 指定分頁的文章列表
* @throws PageIndexOutOfBoundsException 當分頁索引號超出正常範圍時拋出,即pageIndex小於0或大於最大頁索引時。
*/
public List<Article> listArticle(int pageSize, int pageIndex) throws PageIndexOutOfBoundsException{
if (pageSize < 0){
throw new IllegalArgumentException("pageSize不能小於0");
}
if (pageIndex < 1){
throw new IllegalArgumentException("pageIndex不能小於1");
}
// 第一條文章記錄在MySql數據庫中的偏移量
int offset;
throw new PageIndexOutOfBoundsException("");
// 省略一堆代碼
}
參數校驗的作用
如果在對外公開的重要方法開始的位置不插入校驗參數的代碼,有時恐怕方法需要運行到方法內部比較深的位置纔會拋出一個異常來。而且那種情況下,拋出的異常可能就會有各種各樣的了。比如空指針、除零異常等。
這種情況下,很難一眼看出引發這個異常的根源是參數傳錯了。需要對你的代碼進行一番調試才行!如果一開始就在代碼的入口插入了校驗參數的代碼,那麼調用的時候,一眼就能看出來是參數傳錯了導致了一個異常。這樣其他程序員看到這個異常之後就會去看一下你的方法註釋。他一看,哦!原來分頁索引號是從1開始計數的,那麼這個問題就會就此打住,給團隊節省了時間。
參數校驗問題是會影響團隊運行效率的一個很關鍵的因素。所以,請同學們重視起這個問題來。我們都是工程師,團隊作戰的,自己寫代碼快不叫快,整個團隊快起來才叫真的快!用好斷言,可以讓你的代碼更健壯。
tips: Java中默認斷言是不開啓的,所以建議無視Java語言的斷言,自己處理。
什麼時候需要進行參數校驗
我認爲一個方法或者函數在滿足以下條件時有必要進行參數校驗:
- 方法或者函數是對外公開的,不是私有的。
- 參數有可能爲空指針的時候。
- 參數的合理值無法通過方法名、參數名、參數類型一眼看出來的時候!比如上面那個pageIndex是從1開始計數的,但別人並不知道你是從1開始計數的。
如果對每一方法都進行校驗的話,其實挺麻煩的。程序員的時間是很寶貴的,沒這麼多閒工夫。不過在滿足上面條件的情況下,最好還是校驗一下。因爲做了這個校驗,你自己是會稍微浪費幾分鐘的時間,不過從團隊整體來看,總的調試損耗的時間卻降下來了。要記住方法/函數寫出來就是給別人調用的!
參數校驗需要做到什麼程度
我有一個標準,就是把自己當成調用這些代碼的那個人,把自己想象成有可能以任何“姿勢”調用的菜鳥(實際上也有可能是不瞭解你的代碼的大牛)。如果這個時候自己也有可能會犯某些錯(比如沒注意邊界值,沒注意是否可空),那麼這個時候是必須要做校驗的。對於一些已經在其他層做過處理不太可能有錯誤的值的情況,可以不做校驗。比如你的UserService
中有一個簽名爲public void register(User user)
的方法,用於註冊一個用戶。這種情況下,可以只校驗一下user
參數是否爲空,而不用對user
的username
、password
屬性進行校驗(用戶名密碼長度是否合法等)。因爲你在上一層控制器層模型綁定的時候已經做過非常嚴謹的校驗了。當然,這裏如果你有充足的時間,也可以校驗一下。具體做到什麼程度,還需要你根據情況去自己把握。