【Java】一次简单实验经历——社交网络图的简化实现
文章目錄
- 前言
- java簡介
- 引子
- 代碼資源
- 1、實驗簡述
- 1.1 問題簡述
- 1.2 思路
- 1.2.1 淺顯的觀點
- 1.2.2 面臨的選擇
- 2、設計Person類(Version 0.5)
- 2.1 Person類的開始
- 2.1.1 字段的定義
- 2.1.2 構造方法
- 2.1.3 定義對字段的訪問
- 2.2 關于PersonA的一點小修改(Version 0.6)
- 3 危險的半成品(Verson 1.0)
- 3.0.0 引子
- 3.1 FriendshipGraphA
- 3.1.1 字段
- 3.1.2 輔助方法
- 3.1.3 addVertex
- **一碗雞湯**
- 3.1.4 addEdge
- 3.2 FriendshipGraphB
- 4 反思與解決(Version 1.1)
- 4.1 分析出錯的原因
- 4.1.1 所以這只是一個小失誤
- 4.1.2 小陳的測試
- 4.2 針對Version 0.5 中出現(xiàn)的問題進行修復
- 4.2.1 重寫equals方法
- 4.2.2 重寫hashCode方法
- 4.2.3 為什么做這樣的重寫?
- 4.2.4 堅決摒棄原來的寫法
- 4.3 收尾工作
- 4.3.1 小結
- 5 隱藏的錯誤(Version1.2)
- 5.1 一波未平一波又起
- 5.2 回到addEdge
- 5.3 問題的根源
- 5.3.1 為什么會產(chǎn)生依賴?
- 5.3.1.1 PersonB
- 5.3.1.2 PersonA
- 5.4 權衡A和B
- 5.4.1 要發(fā)生拓展了
- 5.4.1.1拓展
- 5.4.1.2 再拓展
- 5.4.1.3 再再再拓展
- 5.4.2 小結
- 6 完成(Verson 1.3)
- 7 總結
- 7.1 Note and improve
- 7.1.1 方案A
- 7.1.2 方案B
- 7.2 通用的經(jīng)驗
- 7.3 后記
- 參考內容
前言
java簡介
Java是一門面向對象的編程語言,不僅吸收了C++語言的各種優(yōu)點,還摒棄了C++里難以理解的多繼承、指針等概念,因此Java語言具有功能強大和簡單易用兩個特征。Java語言作為靜態(tài)面向對象編程語言的代表,極好地實現(xiàn)了面向對象理論,允許程序員以優(yōu)雅的思維方式進行復雜的編程。
參考百度百科
引子
這年,我參加了課程軟件設計,在這門課上,我開始接觸java語言。之前都是用C和C++在寫程序,Java語言一方面摒棄了C++的很多復雜特性,使得編程更加簡單;另一方面,Java這門完全面向對象(不像C++一樣進化不完全)的語言又常常給我們這些初學者帶來一些疑惑。
當所有的代碼都必須使用對象組織起來的時候,我們常常困擾于如何將各個對象聯(lián)系起來。
- 同樣的信息(字段),儲存在哪個對象里更好?
- 同樣的功能(方法),交給哪個對象實現(xiàn)更好?
軟件構造這門課與之前的算法課不同,它更注重教會我們關注代碼的正確性、健壯性和可擴展性、更注重教會我們把代碼零件組裝起來。在這樣的環(huán)境下,我有機會去思考這些平時忽略過去的問題。在之前的實驗中,我往往都是有了想法就去實現(xiàn),而不是多方權衡考量;有的時候是為了完成作業(yè),而不是寫出“好”的代碼。
在這一系列實驗中,我通過親手實現(xiàn)腦子里的各種想法,將它們進行權衡比較,好像從中總結出了一些不太成熟的想法,希望可以和各位交流學習一下。對于我而言,這同樣是階段性總結。
在這篇文章中,我們以初學者的身份完成一個簡單的實驗(后面會附上實驗描述),在這個過程中,我們會寫出具有很多bug的代碼,會產(chǎn)生一些漏洞百出的設計,但是我們也將積累一些java編程的經(jīng)驗,養(yǎng)成一些優(yōu)良習慣。在完成實驗的過程中,我們不斷自我反思,不斷從兩個維度評估寫出來的代碼。
- 橫向維度,我對這一實驗提出了兩種實現(xiàn)方法,在每一部分都將分析它們的差異,和優(yōu)缺點。
- 縱向維度,這兩種實現(xiàn)都是在時間線上迭代開發(fā)完成的。我們將看到,在未來需求未知的情況下,怎樣組織代碼,可以讓程序更好地適應變化。
代碼資源
在本實驗中使用到的所有代碼都已經(jīng)共享到github倉庫以供參考,如有需要可以自行下載
(里面有詳盡的spec,和所有測試用例)
倉庫鏈接在此
然后,我在log.txt文件中也記錄了每個版本的版本號,代碼下載到本地后,可以在git bash中輸入指令
git reset <版本號> 切換到不同版本體驗
1、實驗簡述
本實驗來自CMU 17-214軟件構造課
1.1 問題簡述
-
總體要求:實現(xiàn)并測試一個 FriendshipGraph 類,該類表示社交網(wǎng)絡中的友誼關系,并且可以計算圖中兩個人之間的距離。還需要實現(xiàn)一個輔助類 Person。您應該將社交網(wǎng)絡建模為一個無向圖,其中每個人都連接到零個或多個人,但你的底層圖實現(xiàn)應該是有向的。
-
關于Person類:你可以假設每個人都有一個唯一的名稱。
-
關于FriendshipGraph類:FriendshipGraph類至少需要實現(xiàn)三個方法:addVertex(添加節(jié)點)、addEdge(添加邊)和getDistance 方法——將兩個人(作為 Person)作為參數(shù)并返回人之間的最短距離(一個 int),如果兩個人沒有連接(或者換句話說,沒有任何路徑可以到達),則返回 -1第一個人的第二個人)。
-
關于異常輸入:您可以根據(jù)需要處理不正確的輸入(打印到標準輸出/錯誤、靜默失敗、崩潰、拋出特殊異常等)
-
一些tips:您的圖形實現(xiàn)應該具有合理的可擴展性。我們將使用數(shù)百或數(shù)千個頂點和邊測試您的圖。為您的字段和方法使用適當?shù)脑L問修飾符(公共、私有等)。如果一個字段/方法可以是私有的,它應該是私有的。不要使用靜態(tài)字段或方法,除了 main 方法和常量遵循 Java 代碼約定,尤其是命名和注釋。
1.2 思路
1.2.1 淺顯的觀點
單從實驗要求上看,這其實并不是一個復雜的設計——畢竟只是一個有向圖的實現(xiàn)。
站在圖的角度看,圖中的每一個節(jié)點都被抽象為一個Person,而Person與Person之間的“邊”其實就代表這人與人的“認識”關系。如果A認識B,那么圖中就應當有一條從A到B的邊。
站在Person的角度看,在這個問題中,題目對Person類做了簡化,現(xiàn)實中的Person應當擁有更多的屬性,但是這里僅僅使用唯一的name屬性來描述一個Person。這其實隱式要求了,圖中的每個節(jié)點要具有不同的name,不然沒法從語義上將兩個節(jié)點分開。
1.2.2 面臨的選擇
我們需要抽象Person與Person的認識關系。這在編程中如何表示呢?我經(jīng)過了一番思考,想出了三種方法:
- 有一種很貼近現(xiàn)實的想法。一個人究竟認識誰,這不應該是一個Person的屬性嗎?就像姓名、年齡、身高一樣,應該是Person的一部分,我只要訪問這個Person就應該能夠知道他認識了誰。所以,一個Person認識的所有其他Person都應該儲存在這個Person內部。
- 從圖出發(fā)想法。Person與Person之間的認識關系,這不就是圖中的一條條邊嗎?所以,每一條邊<Person, Person>都應當以某種合適的形式儲存在圖中。
- 再創(chuàng)建一個類Relationship。使用Relationship抽象人與人的認識關系。這樣,圖只用管理一個一個節(jié)點(Person)和一條一條邊(Relationship)。在本文中,我們不對該想法做出實現(xiàn),有興趣的讀者可以嘗試實現(xiàn),再與本文展示的實現(xiàn)進行比較
第二種方法和第三種方法的區(qū)別在于,有沒有把邊抽象為一個類來實現(xiàn)。在第二種方法中,我們使用java提供的集合類將邊變成一種打包的結構,而第三種方法更側重于把每一條邊變成一個實例,我們可以在實驗中感受這兩者的不同。
2、設計Person類(Version 0.5)
在Version 0.5 中我們做的事情并不多,只是一些準備性工作。可以從git提交描述看到,只是
Completed the definition of the ‘naive’ Person class
2.1 Person類的開始
為了解答剛剛提出的問題:怎樣表示Person與Person之間的認識關系,我在這里實現(xiàn)了兩個相似而又不同的Person類:PersonA 和 PersonB。我們可以在后面的編程中看到,Person類的設計是如何影響整個程序的走向的。
2.1.1 字段的定義
首先,我們要定義PersonA和PersonB的字段,它們都擁有相同的字段name,我們將name定義為final,指明一個人不能更換自己的名字(至少現(xiàn)在不能,估計以后也不會有),顯然,要是Person能隨意更改名字,我們的設計會變得很麻煩。
- PersonA:PersonA實現(xiàn)了我們的第一個想法,將一個人認識的所有人當作這個人的屬性,儲存在類的內部。我們可以看到它有兩個字段-- name 和 knows
- PersonB:PersonB則沿用了我們關于圖形的思考方法,將一個人認識另一個人這種聯(lián)系抽象為“邊”,而把關于邊的操作交給FriendshipGraph類來處理,而PersonB只記錄一個人的名字。所以PersonB看起來甚至沒有什么內容。
2.1.2 構造方法
在這個方面,兩個Person還是很相近的。你以為構造函數(shù)只用接受一個String類型的參數(shù),然后把它復制給name就足夠了嗎?
讓我們多思考一會:你希望Person的名字是一堆空格嗎?如果我說“ ”(一個空格)是一個Person,而“ ”(三個空格)是另一個Person,你肯定會覺得很荒謬。或者說,你希望“mike”和“ mike ”別認為是兩個不同的Person嗎?顯然,他們是一樣的,只不過一個名字里有空格,另一個沒有罷了。
所以在這個構造函數(shù)里,我們不僅需要去除兩端的空格,還要對“空名字”拋出異常。這里展示PersonB的構造方法,PersonA的構造方法,只需要為knows指定一個新的HashSet<>()即可。
// Implemented in PersonB.javapublic class PersonB{/* private field ... *//* public methods *//*** Constructs a {@code PersonA} whose name is <i>nameString</i>.* We will automatically strip the spaces at both ends of the nameString to get a concise name. * * <p><strong>Requires</strong>: nameString is not none.</p> * @exception IllegalArgumentException if nameString is empty* */public PersonB(String nameString) {String temp = nameString.trim();if (temp.equals(""))throw new IllegalArgumentException("None Name");else { // knows = new HashSet<PersonA>(); this line is needed in PersonAname = temp;}} }2.1.3 定義對字段的訪問
遵循類定義的原則,我們盡量將字段聲明為private類型。所以對于這些private類型的變量,我們需要定義一些public接口,以供外部獲取信息。
- 我們在PersonA 和 PersonB 中都定義了 getName方法,用于獲取name字段
- 除此之外,在PersonA中,我們還需要定義一些接口,以便外部訪問knows。暫時能想到的是:1、我們需要有途徑向knows中添加Person;2、我們還希望知道某一個Person是不是當前Person認識的;3、我們還想知道一個人究竟認識多少個人。所以我暫時先定義了這三個方法。
- 注意,我們認為Person knows himself 在本實驗中使不允許的。原因在于,在本圖中不該產(chǎn)生從自身到自身的邊。
到此為止,我們看似已經(jīng)實現(xiàn)了兩個簡單的Person類。因為現(xiàn)在我們腦子里的想法不是很多,只能先完成到這一步,它們雖然簡單,但是好像看上去和合理,好像可以完成我們給它們的任務。
其實,我們只要稍微做一下測試就會發(fā)現(xiàn),PersonA中的Isknows更本不能得出正確的答案,或者說,它僅僅在某些特殊情況下能得出答案,很顯然,這個設計是錯誤的。
2.2 關于PersonA的一點小修改(Version 0.6)
前面說到,PersonA的Isknows方法幾乎不能得出正確的答案。原因是,集合類Set的contains方法其實會調用集合中元素類的equals方法判斷一個元素是否在集合中。而我們自定義的PersonA、PersonB類,它們的默認equals方法是比較兩個實例的ID值。實際上,即使我們使用完全相同的字符串創(chuàng)建兩個Person,它們的ID也往往不相同。所以這個調用contains方法并不能解決問題。
我們想到的一個很直接的解決方案是改寫isknows方法,如下。
public boolean isKnows(PersonA pA) {if (pA.getName.equals(name))throw new IllegalArgumentException("Not defined relationship between a person and itself.");boolean flags = false;for (PersonA eA : knows) {if (eA.getName().equals(pA.getName()))flags = true;}return flags;}目前看來,這樣就可以解決問題了。但是,隨著實驗推進,我們會發(fā)現(xiàn),這只是權宜之計,還有一些根本性的問題沒有解決。這樣的設計也不利于我們拓展Person類,未來我們會看到,當越來越多問題發(fā)生時,修改這樣的程序是一個很令人惱火的事情。我們可能會漸漸抓狂。
還好,咱是良心博主,會時不時停下來檢討自己,重新審視自己的代碼,不至于讓問題爆發(fā)時變得不可控。
3 危險的半成品(Verson 1.0)
3.0.0 引子
在上一章,我們簡單的設計了類 PersonA 和類PersonB,剛開始的時候它們看起來很完美,后來,我們做了一些小測試,發(fā)現(xiàn)PersonA中存在bug。所以我們在 Version 0.6 中修復了這個bug,并且對此感到很高興。接下來,我們將正式開始設計 FriendshipGraph(A/B)。
3.1 FriendshipGraphA
3.1.1 字段
FriendshipGraphA 使用 PersonA 來描述每個節(jié)點,因為我們把記住“認識了誰”的任務交給了PersonA,所以看起來FriendshipGraphA很輕松。它只需要記錄這個圖中有哪些節(jié)點就好了,就像這樣:
public class FriendshipGraphA {/* Private fields */private final Set<PersonA> vertexes = new HashSet<>();/* public methods ... */ }3.1.2 輔助方法
在開始設計實驗要求的三個方法前,我們需要兩個“伙計”幫忙。這兩個伙計一個能告訴我們圖中有多少個節(jié)點、一個能告訴我們圖中有多少條邊。它們是 getVertexNum 和 getEdgeNum.
// Implemented in FriendshipGraphA.java/*** Returns the number of vertexes in this graph. If this* graph contains more than {@code Integer.MAX_VALUE} vertexes, returns* {@code Integer.MAX_VALUE}.* * @return the number of vertexes in the graph.* */public int getVertexNum() { /* Assuming that this method is reliable */return vertexes.size();}/*** Returns the number of edges in this graph. If this graph * contains more than {@code Integer.MAX_VALUE} edges, returns* {@code Integer.MAX_VALUE}.* * @return the number of edges in the graph.* */public int getEdgeNum() { /* Assuming that this method is reliable */int sum = 0, temp = 0;for (PersonA pA : vertexes) {temp = sum + pA.knowsNum();if (temp >= 0) {sum = temp;}else {return Integer.MAX_VALUE;}}return sum;}我們必須得假定,這兩個方法是正確的。因為,后續(xù)我們會使用這兩個方法測試addVertex方法和addEdge方法。我們幾乎沒有更基礎的方法可以證明這兩個方法的正確性。如果硬要說的話,在debug窗口中數(shù)一數(shù)graph變量中究竟有幾個節(jié)點,幾條邊,比較一下這些數(shù)字是否與getVertexNum 和 getEdgeNum的返回值相同,或許是個不錯的方法。但是,我并不打算這么做。
3.1.3 addVertex
addVertex將一個 PersonA 類型的實例加入到圖 FriendshipGraphA 中。這時候,我們想起了關于圖的隱式約束,一個簡單有向圖中不能存在相同節(jié)點。也就是說,在我們把待加入的節(jié)點加入到圖中之前,我們需要檢查這個節(jié)點在圖中是否存在。
雖然我們儲存節(jié)點容器是Set,set類型可以避免重復元素的出現(xiàn)。當我們嘗試加入重復元素時,set不會改變容器內的值,從理論上來講,我們可以不做任何檢查,直接調用Set.add,它會自動處理重復節(jié)點的情況。但是,這樣做的壞處是,用戶將無法得知,自己嘗試向圖中加入一個重復節(jié)點。
默默無聞的程序不是好程序,當用戶輸入已經(jīng)存在于圖中的節(jié)點的時候,我們希望程序能夠拋出異常。就像這樣:
// Implemented in FriendshipGraphA.java/*** Add a new vertex into the graph.* <p><strong>Requires</strong>: The new vertex must not have the * same name with any existed vertex in the graph.</p>* * @param the new vertex to be added into the graph* @exception IllegalArgumentException if the new vertex has* the same name with some vertex in the graph* */public void addVertex(PersonA pA) {for (PersonA p : vertexes) {if (p.getName().equals(pA.getName()))throw new IllegalArgumentException("Existed vertex");}vertexes.add(pA);}這里我們使用了笨拙的for循環(huán)來判斷,這是因為我們想起了在Verson 0.5中遇到的問題,Set.contains方法似乎不起作用。當時,我們使用權宜之計,將contains改成for循環(huán),現(xiàn)在,我們漸漸發(fā)現(xiàn),所有需要判斷一個Person是否在容器類中的操作,都需要使用這種笨拙的方法。
在后面,當我們忍無可忍時,會從根本上解決問題。但是,現(xiàn)在,我們嘗試催眠自己:這不是主要問題,我可以接受。(實際上,我們經(jīng)常這樣做,當寫出一些不美觀的代碼時,我們常常選擇性忽略)
或許會有朋友提出下面這種寫法,因為他注意到Set類的add方法是有返回值的。我們是否可以根據(jù)返回值來判斷Person在不在集合中呢?
public void addVertex(PersonA pA) {if (!vertexes.add(pA))throw new IllegalArgumentException("Existed vertex");}實際上,add方法判斷一個元素是否在集合中的程序邏輯與contains沒什么區(qū)別,這種方法同樣不管用。
當然,我們已經(jīng)提前寫好了測試用例,上面的addVertex代碼通過了我們的測試。
一碗雞湯
接下來,我們將完成實驗規(guī)定的第二項內容。現(xiàn)在讓我們停下來想一想,你會發(fā)現(xiàn),我們做了那么多準備工作,現(xiàn)在才剛剛開始實驗的第二項內容。
實際上在很多項目中,準備工作是必要的,沒有人希望自己頂層的業(yè)務邏輯跑在不可靠的代碼上。只有我們做好了大量基本類、基本方法的準備,頂層開發(fā)才能變得得心應手。
但是,我們后面會發(fā)現(xiàn),前面的準備還是不充分的,其實我們走了一些彎路。如果能在一開始就意識到代碼中的問題,我們就可以減少很多修改的工作量。
不幸的是,以上的這一段話都是站在“事后諸葛亮”的角度說的,當我們作為初學者接受一項工作時,往往并沒有那么多前瞻性的見解,走些彎路很正常,不過希望大家在一次次腦溢血的經(jīng)歷中吸取教訓,最終總結出一套經(jīng)得起考驗并且不斷完善的代碼編寫體系。
3.1.4 addEdge
在這一節(jié),我們將完成FriendshipGraphA的addEdge方法,并對他進行測試。這里就不對addEdge做過多的說明了,直接上代碼。
// Implemented in FriendshipGraphA.java/*** <p>Add a new edge into the graph, which represents one person knows another.</p>* * <p>Obviously, these two vertex should be already in the graph.* And the edge should have not existed in the graph. </p>* * @param srcA the source of the edge.* @param dstA the destination of the edge.* @exception IllegalArgumentException if srcA or dstA does not exist in the graph* @exception IllegalArgumentException if edge <srcA, dstA> has already existed in the graph* */public void addEdge(PersonA srcA, PersonA dstA){boolean flag = false;for (PersonA p : vertexes) {if (p.getName().equals(srcA.getName())) {flag = true;break;}}if (!flag)throw new IllegalArgumentException("srcA not existed in the graph");flag = false;for (PersonA p : vertexes) {if (p.getName().equals(dstA.getName())) {flag = true;break;}}if (!flag)throw new IllegalArgumentException("dstA not existed in the graph");if (srcA.isKnows(dstA))throw new IllegalArgumentException("Duplicated edge");else srcA.addKnows(dstA);}其實這個方法的邏輯十分簡單,就是將一個PersonA加入到 srcA.knows 中。但是,為了維護這個圖的性質,我們需要進行一些額外的判斷。
我們使用了大量代碼去判斷一個節(jié)點是否在圖中(使用了18行的篇幅),這使得代碼看起來十分臃腫。顯然,應該有更好的寫法,但是,我們又一次催眠了自己,“既然都能完成相應的功能,何必去追求簡潔呢?”
緊接著,我們對addEdge方法進行了測試,測試通過了,看起來沒有什么問題了,我們可以進行下一步開發(fā)了。測試代碼如下:
// Implemented in FriendshipGraphATest@Testpublic void addEdgeTest() {FriendshipGraphA graphA = new FriendshipGraphA();PersonA p1 = new PersonA("Mike");PersonA p2 = new PersonA("John");PersonA p3 = new PersonA("Kiki");PersonA p4 = new PersonA("Dong");graphA.addVertex(p1);graphA.addVertex(p2);graphA.addVertex(p3);/* Test */assertEquals(0, graphA.getEdgeNum());graphA.addEdge(p3, p1);assertEquals(1, graphA.getEdgeNum());graphA.addEdge(p2, p1);assertEquals(2, graphA.getEdgeNum());graphA.addEdge(p1, p2);assertEquals(3, graphA.getEdgeNum());/* not exist vertex case */expectedEx.expect(IllegalArgumentException.class);expectedEx.expectMessage("srcA not existed in the graph");graphA.addEdge(p4, p2);assertEquals(3, graphA.getEdgeNum());/* duplicated edge case */expectedEx.expect(IllegalArgumentException.class);expectedEx.expectMessage("Duplicated edge");graphA.addEdge(p1, p2);assertEquals(3, graphA.getEdgeNum());}3.2 FriendshipGraphB
有了 FriendshipGraphA 的基礎,我們很快就完成了 相關代碼的實現(xiàn)。這兩個類只有兩個方法不太一樣:getEdgeNum 和 addEdge。
除此之外,兩者幾乎一樣。下面我們貼出FriendshipGraphB中getEdgeNum和addEdge的實現(xiàn)。
有了FriendshipGraphA測試成功的經(jīng)歷,我們信誓旦旦地對FriendshipGraphB做了相同的測試。
然而,問題出現(xiàn)了。
測試addEdge方法的方法拋出了異常,原來是測試函數(shù)中調用了getEdgeNum方法,而該方法在下述位置產(chǎn)生了錯誤。
現(xiàn)在,壓力來到了程序員這邊。
4 反思與解決(Version 1.1)
在上一章,我們完成了FriendshipGraph(A/B)類的半成品。我們依照設計FriendshipGraphA的經(jīng)驗完成了FriendshipGraphB的設計。但是卻在測試階段出現(xiàn)了問題。在本章中,我們將深入理解產(chǎn)生這些問題的原因,并且提出一些隱藏的危險情況(這就是我們說Verson 1 是危險的半成品的原因)
4.1 分析出錯的原因
4.1.1 所以這只是一個小失誤
錯誤提示告訴我們:
the return value of "java.util.Map.get(Object)" is null.亦即,get方法返回了空值。也就是說,當我們遍歷Map的所有keys,利用這些keys去取Map中的值時,卻得到了空。也就是說,字典中這個鍵下面不包含任何元素。稍微往前翻看一下我們的代碼,我們就會發(fā)現(xiàn),當我們把一個Vertex加入到圖中的時候,我們并沒有在字典Edge中添加任何有關這個節(jié)點的信息,第一次添加來自于第一次調用addEdge,且第一個參數(shù)為該節(jié)點時。
所以,我們或許要對addVertex方法做一下修改:
// Implemented in FriendshipGraphB.javapublic void addVertex(PersonB pB) {for (PersonB p : vertexes) {if (p.getName().equals(pB.getName()))throw new IllegalArgumentException("Existed vertex");}vertexes.add(pB);edges.put(pB, new HashSet<>()); /* A new line added */}到此為止,你長吁一口氣。但是為了保險起見,你把自己的代碼拿給細心的好友小陳檢查,小陳通讀了你的每一個類、每一個測試用例,就在連小陳都要覺得無懈可擊的時候。他突然靈光一動,在你的測試代碼中留下了這么幾行字。
4.1.2 小陳的測試
// Implemented in FriendshipGraphB.java@Testpublic void addEdgeTest(){/* ... context ... */PersonB mike = new PersonB("Mike");PersonB mike1 = new PersonB("Mike");PersonB bob = new PersonB("Bob");/* Person named Mike has already been added into the graph */graphB.addVertex(bob);graphB.addEdge(mike, bob);graphB.addEdge(mike1, bob); /* this operation should throw an exception, but not *//* ... context ... */}這段測試代碼不會產(chǎn)生任何錯誤,它可以完美的通過。但是,你已經(jīng)冷靜不下來了。因為,最后一行代碼應該出錯的呀。從語義上看,mike 和 mike1完全指的是同一個人。當我們嘗試調用
graphB.addEdge(mike1, bob);時,理應當觸發(fā)IllegalArgumentException(“Duplicated edge”)異常。但是小陳設計的測試用例卻繞開了代碼內部對重邊的檢查。
重新審視我們的代碼,我們很快就發(fā)現(xiàn),自己又犯了與Version 0.5 一樣的錯誤。因為我們使用了Map的containsKey方法,而這個方法并沒有辦法將擁有相同name字段的不同Person實例識別為同一個人。
// Snippet from addEdge in FriendshipGraphB.java到這里,相信你一定已經(jīng)意識到了問題的嚴重性。其實,在Version 0.5 的時候,咱們就已經(jīng)發(fā)現(xiàn)了這個問題,但是,我們不斷使用所謂的“權宜之計”,最后,當我們意識到這個問題可能會使編程變得十分艱難的時候,已經(jīng)在錯誤的道路上走了很遠了。
4.2 針對Version 0.5 中出現(xiàn)的問題進行修復
4.2.1 重寫equals方法
想要使用Set,Map等集合類的contains等方法。我們需要重寫Person類的equals方法。因為包括Set在內的容器類型并沒辦法知道它們容納了什么類(今天是Person,明天可能是Graph),所以這些方法內部都會調用內容類的equals方法來判斷其中內容是否相等。
而默認equals方法的行為是比較兩個實例是否具有相同ID值。這是java特性,所有類的實例通過唯一的ID區(qū)分,我們可以在debug模式下觀察變量得知ID值。所以,我們需要覆蓋equals方法的行為,將其變成比較兩個實例的name字段值。如下:
// Implemented both in PersonA.java and PersonB.java/*** <p>Compares this string to the specified object. * The result is {@code true} if and only if the argument is not null * and is a {@code PersonA} object that has the same name as this object. </p>* * @param obj an Object The object to compare this {@code PersonA} against* @return {@code true} if the given object represents a {@code PersonA} * equivalent to this person, {@code false} otherwise.* */@Overridepublic boolean equals(Object obj){if (!(obj instanceof PersonA)) /* PersonB in PersonB.java */return false;if (obj == this)return true;return ((PersonA)obj).getName().equals(this.name); /* convert to PersonB in PersonB.java */}這里我們遞歸調用了String類的equals方法,這個方法會比較兩個String實例中儲存的字符串是否相等,這與我們的目標是一致的。
為了驗證重寫的equals方法確實管用,我們需要對它進行測試,這很重要!!!任何方法在投入使用之前一定要經(jīng)過嚴格的測試,保證它符合spec中規(guī)定的行為。這里為了不占用過多篇幅,就不展示測試代碼了,git倉庫都可以獲得。請相信筆者已經(jīng)對equals方法做了詳盡的測試:)
所謂spec,可以通俗理解為方法前的一大段注釋。注釋中規(guī)定了方法使用的前提條件和在某些情況下的行為。而對于spec中未定義的情況,方法的行為是未知的。
4.2.2 重寫hashCode方法
光有equals方法還不夠,我們現(xiàn)在還不能使用contains方法。
在源代碼中也附上了useContainsTest代碼,感興趣的讀者可以把PersonA、PersonB內重寫的hashCode方法注釋掉,這時候useContainsTest測試是無法通過的。
這還需要重寫hashCode方法,如下:
// Implemented in PersonA.java and PersonB.java@Overridepublic int hashCode() {return name.length() + (int)(name.charAt(0));}這里沒有為hashCode編寫spec是因為我們“繼承”了Object類對hashCode的spec。
4.2.3 為什么做這樣的重寫?
這根我們使用了HashSet和HashMap有關,這兩個容器類在內部都通過哈希表提高查找效率,同樣,它們無法預測儲存元素的類型,所以都會調用元素類的hashCode方法產(chǎn)生哈希值。
我們調用contains(arg)和containsKey(arg)方法執(zhí)行的內部邏輯是這樣的:
- 根據(jù)arg的哈希值找到相應的“桶”。
- 在桶中逐個選出元素調用equals方法,比較與arg是否相同。
而默認hashCode方法可能無法保證為每個同名的Person實例產(chǎn)生相同的哈希值,所以我們需要修改hashCode的行為,使得它在任何情況下為同名的Person實例產(chǎn)生相同的哈希值。
當我們完成了這兩個方法的重寫,就可以通過useContainsTest測試了。
這一部分我使用的筆力不多,覺得筆者在這一部分沒有講清楚的朋友,可以參考下面這兩篇文章。其中第一篇文章也引用了第二篇文章。俺也是從這里學習到的。
Java中Set的contains()方法
equals()與hashCode()方法協(xié)作約定
4.2.4 堅決摒棄原來的寫法
前面我們從實踐的角度體驗了之前的寫法有多么糟糕,現(xiàn)在我們需要從理論上摒棄這種寫法,雖然重寫hashCode不是必要的,但是可能我們需要把重寫equals放進我們的編程法則中。
- 從上面的例子來看,如果我們不重寫equals方法,我們極易寫出*“屎山代碼”*,從美學角度十分令人不快
- 從降低耦合度的角度。不提供equals接口會大大提高代碼耦合度。所有要使用到Person的其他class,如果需要判斷兩個Person實例是否相等,那么這個類就必須要知道Person內部有name字段,它要知道必須通過這個字段來判斷兩個Perso實例是否相等。這與我們的編程原則相悖。一個好的設計應該允許各個部分在盡可能少知道其他部分的情況下使用其功能。
- 從提高可拓展性的角度。我們想要對不重寫equals的代碼做修改將變得十分困難。考慮這樣一種情況,你關于Person類和FriendshipGraph類的設計得到了導師的認可,現(xiàn)在導師將一個描述小區(qū)人際關系的項目交給你。這個時候,你需要為Person類添加很多字段用于描述一個住戶,而且你發(fā)現(xiàn),一個小區(qū)中有許多同名住戶,這個時候使用name字段來判斷兩個住戶是否為同一住戶已經(jīng)不合適了。可能住址更管用,這就以為著,在所有判斷兩個住戶是否為同一住戶的時候,你需要把name字段換成 住址+名字——這是一個所有程序員都不愿意嘗試的大工程。
4.3 收尾工作
既然我們已經(jīng)重寫了equals方法和hashCode方法,我們就可自由地使用之前一直不敢使用的contains方法和containsKey方法了。接下來,讓我們把相關替換做完,然后,將版本升級到Version 1.1吧!
4.3.1 小結
很多時候重寫equals方法都是一個必須的選擇,尤其是你的代碼中有大量比較運算的時候,重寫equals方法可以顯著降低耦合度、也能夠增加程序面對不同情況的應變力。
重寫hashCode卻不是必須的。它往往和我們選擇的數(shù)據(jù)類型有關。比如在此例中,我們使用了HashSet容器,所以有這方面的需要。但是如果我們使用了類似ArrayList這樣內部不使用哈希表實現(xiàn)的容器類,可能就沒有重寫hashCode的需要了。
但是,如果你選擇重寫hashCode,就意味著你設計的類可以在適配java大多數(shù)集合類。如果你可以保證自己的類是immutable的,它就可以作為 Map 的鍵值來使用。
5 隱藏的錯誤(Version1.2)
5.1 一波未平一波又起
一個好習慣
每次做完修改后,都去跑一下測試用例。
做完了對兩個方法的重寫后,我們也更新了代碼的其他部分,現(xiàn)在,程序看起來十分干凈簡潔。我們運行測試代碼,原來的測試依然能夠通過,而且在addEdgeTest中小陳給出的測試用例也會拋出異常了。說明,現(xiàn)在我們可以檢測到不同對象實例同名的情況了。
再然后,我想著,把小陳的測試代碼放到FriendshipGraphA中進行測試,這一測,果然出問題了。
有同樣問題的代碼也沒能拋出異常。(這一部分已經(jīng)更新在Version 1.1 中,讀者可以嘗試。結果為,使用addEdge加入重邊,卻不拋出異常。)
5.2 回到addEdge
// Implemented in FriendshipGraphA.javapublic void addEdge(PersonA srcA, PersonA dstA){if (!vertexes.contains(srcA))throw new IllegalArgumentException("srcA not existed in the graph");if (!vertexes.contains(dstA))throw new IllegalArgumentException("dstA not existed in the graph");if (srcA.isKnows(dstA)) // Check if duplicate edges existthrow new IllegalArgumentException("Duplicated edge");else srcA.addKnows(dstA);}當我們帶著懷疑的眼光重新審視這一段代碼時,我們就會發(fā)現(xiàn),我們自然而然地犯了一個不易察覺的錯誤。
在第9行到第12行,我們默認地把srcA當作是graph中的節(jié)點了。
就如同下面這張圖展示的那樣,Vertexes集合中儲存了一系列PersonA對象的“指針”,其中一個指向了名為Mike的對象實例。然后我們創(chuàng)建了一個新的PersonA實例也命名為Mike。
現(xiàn)在讓我們看看執(zhí)行addEdge操作后各個實例之間的關系。這個時候聰明的你肯定已經(jīng)發(fā)現(xiàn)問題了。
srcA指向的Mike實例不一定是Vertexes中儲存的那個Mike實例。我們把Bob加入到了一個Mike實例的knows集合中,但是這個Mike實例并不是圖的一部分。我們的操作并沒有對圖完成修改。
而我們的代碼之所以會犯錯,是因為下面這種使用了匿名變量的用法是十分普遍的。幾乎在所有情況下,匿名變量都不在圖中,那么我們的addEdge方法就會變得十分危險。
為了解決這個問題,我們或許需要把addEdge方法更改為下面這種版本
// Implemented in FriendshipGraphA.javapublic void addEdge(PersonA srcA, PersonA dstA){if (!vertexes.contains(dstA))throw new IllegalArgumentException("dstA not existed in the graph");PersonA src = null; /* Find the certain instance stored in Vertexes */for (PersonA item : vertexes)if (item.equals(srcA))src = item;if (src == null) /* NotFound throw exception */throw new IllegalArgumentException("srcA not existed in the graph");if (src.isKnows(dstA)) /* Check if duplicate edges exist */throw new IllegalArgumentException("Duplicated edge");else src.addKnows(dstA);}5.3 問題的根源
這樣的bug雖然很容易解決,但有時候很難被察覺。我們希望盡力避免寫出這樣的代碼,而問題的根源出自Person類的設計——因為Person類掌握了太多信息,導致,Graph不得不“請求”某一個特定的Person來解決問題。比如在此例中,認識的人一定要加入到特定的那個在圖中的"Mike"中才可以。
5.3.1 為什么會產(chǎn)生依賴?
這一節(jié)的內容充斥著個人見解,各位讀者可以揚棄地采納
從直觀上,我們很容易接受這樣一個觀點:一個類掌握的信息越多,那么它的每一個實例就越具有唯一性,被替換的難度就越大。
5.3.1.1 PersonB
對于PersonB類,我們可以創(chuàng)建很多實例,只要它們都名為Mike,那么在程序的各個角落,這些實例的產(chǎn)生的效果都是等價的。因為每一個實例包含的內容很少,很容易被復制,也很容易互相替換。
5.3.1.2 PersonA
而對于PersonA類,如果我們想保持每一個Mike實例的等價性,除了維護相同的名字以外,我們還要保證它們的knows字段是相同的。這會給我們帶來很多困擾,各種保持一致性的方法都好似不盡如人意:
- 每個Mike實例將自身在knows字段上的修改同步到其他Mike上,這意味著每個Mike都要“知道”其他Mike。在項目的任何時候,只要產(chǎn)生了一個PersonA實例,這個實例就要和現(xiàn)存的所有PersonA實例想比較,找到一個同名的實例后,它就要同步該實例的其他屬性。同時,修改任何一個Mike都要保證其他Mike收到相同修正。這是不現(xiàn)實的!!!
- 所有Mike實例的knows共同指向一個Set。既然它們共享了knows,那么就一樣了吧。但是這種做法的安全性極低。因為任何一個Mike實例都有權利修改公有信息,只要利用其中一個Mike實例就可以對全局造成不可恢復的信息破壞。
既然保持每個PersonA同名實例的一致性那么困難(我們可能根本不會選擇去做這樣的事情),這就意味著總有一個實例是特殊的,它記錄這其他同名實例內不含有的內容。就像在本例中提到的那樣,一旦我們想要為Mike添加一位認識的人,我們就一定要找到圖中,vertex集合內指向的那個Mike實例。
5.4 權衡A和B
在前面的內容中,我們一手實現(xiàn) A-方案,一手實現(xiàn) B-方案。現(xiàn)在,或許是時候做一個小結了。你更喜歡哪種設計呢?
- 從代碼量上看,二者并沒有十分明顯的區(qū)別。并沒有說,某一種設計可以簡化實現(xiàn),
- 方案A中,Person類既負責保存Person的自身屬性,又負責記錄它和其他Person的關系。其實,在Person類中,我們就已經(jīng)實現(xiàn)了圖的大部分邏輯,Graph只是將Person做了一個打包封裝,從而對外界呈現(xiàn)出圖的狀態(tài)。兩者其實已經(jīng)糅合在一起了。比如說,添加邊的時候,Graph只能完成其中的一部分,還有一部分要在Person中去完成。
- 方案B中,Person類只起到儲存信息的作用,Person都不關心外部世界,它不完成任何業(yè)務邏輯;所有關于圖的操作都在Graph中實現(xiàn)。此時,Person和Graph的關系是區(qū)分得比較開的,業(yè)務邏輯是分離的。
我們可能更建議采用方案B。因為,它的耦合度比較低。
耦合度就是各個模塊之間的聯(lián)系程度,耦合度越高,程序就越會牽一發(fā)而動全身。
較高的耦合度帶來的副作用往往不體現(xiàn)在當下,而是體現(xiàn)在未來。在本項目中,無論是實現(xiàn)方案A還是實現(xiàn)方案B,都沒有給我們帶來太多的困難。但是如果要考慮到程序將來的拓展升級,二者的區(qū)別就產(chǎn)生了。
5.4.1 要發(fā)生拓展了
5.4.1.1拓展
考慮這樣一種情況,如果我們想把社交網(wǎng)絡拓展成帶權有向圖——這很好理解,權重可以表示兩人的親密程度,同樣是認識的關系,親密等級可能不一樣。
在方案B中,我們可以保持Person類不動,在另外抽象出一個Relationship類,用來表示一個人認識另一人的關系,然后Graph類復雜統(tǒng)籌管理Person和Relationship,我們只要把Person和Relationship都設計為immutable,這樣的結構是很好實現(xiàn)的。此時Graph類中的edge就不再是一個Map<Person, Set< Person >>,而是Set< Relationship>(或者其他結構,比如List等等)。
其實這里就是開頭提到的第三種實現(xiàn)方法。
// A possibility derived from option Apublic class Graph{private final Set<Person> vertices;private final Set<Relationship> edges;... }public class Person{private final String name;... }public class Relationship{private final Person src;private final Person dst;private int weight;... }在方案A中,我們發(fā)現(xiàn),Person類記錄了兩個Person之間的聯(lián)系,所以勢必要在Person中做出修改,而且原來的記錄邊的數(shù)據(jù)結構可能已經(jīng)力不從心了,或許我們需要使用Map記錄邊的終點和權重,所以Person需要提供新的對外接口。而在Graph中,我們也要修改方法的邏輯實現(xiàn)一適配Person中的變化。
// A possibility derived from option B public class Graph{private final Set<Person> vertices;... }public class Person{private final String name;// key is the dstination of edge, the value is the weightprivate final Map<String, int> knows;... }5.4.1.2 再拓展
現(xiàn)在我們要把這個圖放到更廣義的用途上,比如用它圖來解決網(wǎng)絡流問題,所以每個邊上既要記錄最大流量,又要記錄已經(jīng)使用的流量,可能還要記錄剩余流量。
在方案B的基礎上,我們可以做出如下修改:
// A possibility derived from option Bpublic class Graph{private final Set<Vertex> vertices;private final Set<Edge> edges;... }public class Vertex{private final String label;... }public class Relationship{private final Vertex src;private final Vertex dst;private int capability;private int used;private int margin;... }而在方案A的基礎上,我們可能需要把圖變成這個樣子???
// A possibility derived from option Apublic class Graph{private final Set<Vertex> vertices;... }public class Vertex{private final String label;private final Set<ClassName> knows;... }// I just don't know how to name this class, given how werid it looks public class ClassName {private final String dst;private int capability;private int used;private int margin;... }5.4.1.3 再再再拓展
…
5.4.2 小結
隨著我們不斷增加圖中的信息,我們會發(fā)現(xiàn),在方案A中,Person所占的比重越來越大,以至于到最后,所有的信息儲存和業(yè)務邏輯都在Person中實現(xiàn),而Graph只是套在外面的一個外殼,我們只是通過這樣一個外殼來保持外界使用這個圖。當我們想在圖中添加一條新的特性的時候,我們很像把它放在Graph內部實現(xiàn),但是又不得不牽扯到對Person的修改。因為Person占有了很多信息,所以Graph對每個Person都十分依賴,業(yè)務邏輯十分混亂,就像下圖展示的關系一樣。
在方案B中則不一樣,因為各個類的分工很明確,Vertex和Edge只是作為信息的載體,主要的邏輯都在Graph中實現(xiàn)。當我要拓展Edge的功能時,就修改Edge的表達;需要拓展Vertex的功能時,就修改Vertex的表達;需要增加圖的限制時,就在Graph中添加相應的代碼。而且每種改動對其他類的影響是相對比較少的。
6 完成(Verson 1.3)
現(xiàn)在我們需要完成最后的一個方法getDistance。這里也沒有什么好說的,直接看實現(xiàn)。之前使用PersonA和GrpahA的時候并無過多不適,但是如今一寫這個求單元最短路的方法,就覺得束手束腳。
首先,如果是單純寫一個單元最短路的算法是用不到這么多篇幅的。
- 但是我們需要對一些特殊的輸入做出特殊反應
- 而且還是 5.2 節(jié)提到的那個問題,我們一定要找到那個特定的PersonA實例,只有在那個實例中才儲存了他認識的人。這個特性有時候會令人抓狂的。
相形之下,方案B的實現(xiàn)就會稍微簡潔一點:
public int getDistance(PersonB srcB, PersonB dstB) {if (srcB == null || dstB == null)throw new IllegalArgumentException("Null Person");if (!vertexes.contains(srcB))throw new IllegalArgumentException("'"+srcB.getName()+"' not in the graph");if (!vertexes.contains(dstB))throw new IllegalArgumentException("'"+dstB.getName()+"' not in the graph");if (srcB.equals(dstB)) /* when src == dst return 0 */return 0;int dis = 0;Set<PersonB> unvisited = new HashSet<>(vertexes); /* unvisited nodes */Set<PersonB> preAs = new HashSet<>();Set<PersonB> nowAs = new HashSet<>();unvisited.remove(srcB);preAs.add(srcB);boolean find = false; // not find dstAFINDER:while (!preAs.isEmpty()) { // while we have new vertexes to visit++dis;for (PersonB item : preAs) {for (PersonB dst : edges.get(item)) {if (unvisited.remove(dst)) {nowAs.add(dst);if (dst.equals(dstB)) {find = true;break FINDER;}}}}preAs.clear();preAs.addAll(nowAs);nowAs.clear();}if (!find)return -1;return dis; }到了這里,實驗已經(jīng)接近尾聲了。每當?shù)搅诉@個時候,一方面我們長吁一口氣,放下了心中的石頭;另一方面,我們又要對這次實驗做出總結,準備收拾行囊 再出發(fā)。
7 總結
7.1 Note and improve
就這個實驗而言談一談我們有什么可以改進的,比如,是否有冗余的代碼?是否有需要增添的代碼?是否有一些結構是我們使用類變得困難了?
7.1.1 方案A
private final Set<PersonA> knows;在PersonA中,我們的knows是如上設計的。一開始,我們的想法是,在PersonA內部可以訪問到他認識的人,然后又可以從他認識的人出發(fā),繼續(xù)向外尋找。但是在 5.2 節(jié)我們發(fā)現(xiàn),我們無法保證knows中指向的Person實例一定儲存了我們想要的信息。
于是,knows集合中對我們唯一有用的信息就是,認識的其他人的名字了。
所以,是不是可以把knows改成這樣呢?
同樣,addKnows和isKnows的簽名也可以改為
public void addKnows(String name);public boolean isKnows(String name);另外,我們發(fā)現(xiàn)knowsNum()方法幾乎沒有什么用處,因為大多數(shù)時候我們并不關心一個人認識剁手個人。反而,我們其實需要一個getKnows方法。因為我們發(fā)現(xiàn)有些情況下,我們想要訪問一個人認識的所有人,那么從PersonA獲得knows的一個拷貝就很有必要。
public Set<String> getKnows(){Set<String> ans = new HashSet<>();if (knows.isEmpty())return ans;ans.addAll(knows);return ans;}7.1.2 方案B
在方案B的Graph中,我們使用了如下數(shù)據(jù)結構:
private final Set<PersonB> vertexes;private final Map<PersonB, Set<PersonB>>;使用PersonB作為Map的鍵值的好處是,當我們不再按照Person的name判斷兩個人是否認識的時候,或者需要使用多種屬性判斷的時候,可以僅修改Person的equals方法,而不用對Graph做過多的修改。
如果我們要這樣做,PersonB必須是一個immutable類型。這要求PersonB內部的值在創(chuàng)建后就不能改變。為了支持這一點,我們沒有提供修改PersonB內部字段的方法,而且Person字段都被private final
修飾。
7.2 通用的經(jīng)驗
經(jīng)過這個實驗,我們學習到了許多java編程的經(jīng)驗,下面然我們來總結一下。
- 一定要從spec(javaDoc)開始。寫spec的好處實在太多了。它記錄了方法在不同情況下的表現(xiàn),既方便他人使用,又方便自己查閱。初學者可能覺得寫不寫spec無所謂,然而當我們要同時編寫十幾個類,類之間又有復雜的依賴關系的時候,如果我們不為每個方法寫好spec,很有可能會在編程的中途忘記某個方法的作用,或者忘記它在特殊輸入下的返回值,或者忘記某個參數(shù)的含義,或者忘記它會拋出什么異常…
- 先寫測試代碼。如果真的要深入學習java語言,我們一定要養(yǎng)成先寫測試代碼,再完成函數(shù)的習慣。因為測試代碼與方法的具體實現(xiàn)是無關的,它只檢查方法的表現(xiàn)與spec所規(guī)定的是否一致。當我們編寫好spec后,我們有很多種方法去實現(xiàn),但是只有通過測試方法,我們才能驗證自己的實現(xiàn)是否正確。
- 所以編程的順序是spce — 測試用例 — 實現(xiàn)。這一點很重要。而且每一次我們修改了自己的代碼,一定要使用測試驗證修改的正確性。
- 如果我們自定義了一些類,而且這些類的作用是管理數(shù)據(jù),那么,在這些類上重寫equals方法往往很有必要;而且我們可以選擇性地重寫hashCode方法。
- 我們要避免寫出耦合度很高的程序,要盡量降低類與類之間的聯(lián)系。我們希望每個類各司其職,因為耦合度低的代碼方便未來進行更新修改。
7.3 后記
這篇博客基本上是跟著代碼的推進寫下來的。
第一次使用git進行版本管理,有的時候分支弄的比較混亂,在git.log里可以看到,有時候同一個Version我提交了很多次,就是因為對git的使用不熟悉。如果有朋友下載了代碼,我們首先在這里說一聲抱歉。
然后其實測試代碼也有一些不是很規(guī)范的地方,但是做完之后已經(jīng)不想再去大刀闊斧地修改了。但是,基本上各種情況都覆蓋到了,如果有朋友發(fā)現(xiàn)了疏漏之處,請務必在評論區(qū)指出!
這篇博客是個人實驗經(jīng)歷的分享,但是也希望能夠遇見更好的想法,歡迎大家提出自己的觀點。
最后,希望我走過的彎路能夠給你們帶來幫助。
參考內容
Java中Set的contains()方法
equals()與hashCode()方法協(xié)作約定
總結
以上是生活随笔為你收集整理的【Java】一次简单实验经历——社交网络图的简化实现的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 二分图——洛谷P3386 【模板】二分图
- 下一篇: java无参_Java——类的无参、带参