JavaRush /בלוג Java /Random-HE /For and For-Each Loop: סיפור על איך חזרתי, חזרתי, אבל לא ...
Viacheslav
רָמָה

For and For-Each Loop: סיפור על איך חזרתי, חזרתי, אבל לא חזרתי

פורסם בקבוצה

מבוא

לולאות הן אחד המבנים הבסיסיים של שפות תכנות. לדוגמה, באתר אורקל יש קטע " שיעור: יסודות השפה ", שבו ללולאות יש שיעור נפרד " הצהרה ". בואו נרענן את היסודות: הלולאה מורכבת משלושה ביטויים (הצהרות): אתחול (אתחול), תנאי (סיום) והגדלה ( הגדלה):
For and For-Each Loop: סיפור על איך חזרתי, חזרתי, אבל לא חזרתי - 1
מעניין לציין שכולם אופציונליים, כלומר אנחנו יכולים, אם נרצה, לכתוב:
for (;;){
}
נכון, במקרה הזה נקבל לולאה אינסופית, כי איננו מציינים תנאי ליציאה מהלולאה (סיום). ביטוי האתחול מבוצע פעם אחת בלבד, לפני שהלולאה כולה מבוצעת. תמיד כדאי לזכור שלמחזור יש היקף משלו. המשמעות היא שאתחול , סיום , הגדלה וגוף הלולאה רואים את אותם משתנים. תמיד קל לקבוע את ההיקף באמצעות פלטות מתולתלות. כל מה שנמצא בתוך הסוגריים לא נראה מחוץ לסוגריים, אבל כל מה שמחוץ לסוגריים נראה בתוך הסוגריים. אתחול הוא רק ביטוי. לדוגמה, במקום לאתחל משתנה, אתה יכול בדרך כלל לקרוא למתודה שלא תחזיר כלום. או פשוט לדלג על זה, להשאיר רווח ריק לפני הנקודה-פסיק הראשון. הביטוי הבא מציין את תנאי הסיום . כל עוד זה נכון , הלולאה מבוצעת. ואם false , איטרציה חדשה לא תתחיל. אם תסתכל על התמונה למטה, אנו מקבלים שגיאה במהלך ההידור וה-IDE יתלונן: הביטוי שלנו בלולאה אינו ניתן להשגה. מכיוון שלא תהיה לנו איטרציה אחת בלולאה, נצא מיד, כי שֶׁקֶר:
For and For-Each Loop: סיפור על איך חזרתי, חזרתי, אבל לא חזרתי - 2
כדאי לשים עין על הביטוי בהצהרת הסיום : הוא קובע ישירות אם לאפליקציה שלך יהיו לולאות אינסופיות. תוספת היא הביטוי הפשוט ביותר. הוא מבוצע לאחר כל איטרציה מוצלחת של הלולאה. וגם על הביטוי הזה אפשר לדלג. לדוגמה:
int outerVar = 0;
for (;outerVar < 10;) {
	outerVar += 2;
	System.out.println("Value = " + outerVar);
}
כפי שניתן לראות מהדוגמה, כל איטרציה של הלולאה נגדיל במרווחים של 2, אך רק כל עוד הערך outerVarקטן מ-10. בנוסף, מכיוון שהביטוי במשפט ההגדלה הוא למעשה רק ביטוי, זה יכול להכיל כל דבר. לכן, אף אחד לא אוסר להשתמש בירידה במקום בתוספת, כלומר. להפחית ערך. עליך תמיד לעקוב אחר כתיבת התוספת. +=מבצע קודם עלייה ואחר כך הקצאה, אבל אם בדוגמה למעלה נכתוב את ההיפך, נקבל לולאה אינסופית, כי המשתנה לעולם outerVarלא יקבל את הערך שהשתנה: במקרה זה הוא =+יחושב לאחר ההקצאה. אגב, זה אותו דבר עם מרווחי צפייה ++. לדוגמה, הייתה לנו לולאה:
String[] names = {"John","Sara","Jack"};
for (int i = 0; i < names.length; ++i) {
	System.out.println(names[i]);
}
המחזור עבד ולא היו בעיות. אבל אז הגיע האיש המשחזר. הוא לא הבין את התוספת ופשוט עשה את זה:
String[] names = {"John","Sara","Jack"};
for (int i = 0; i < names.length;) {
	System.out.println(names[++i]);
}
אם סימן ההגדלה מופיע לפני הערך, זה אומר שתחילה הוא יגדל ולאחר מכן יחזור למקום שבו הוא מצוין. בדוגמה זו, נתחיל מיד לחלץ את האלמנט באינדקס 1 מהמערך, ונדלג על הראשון. ואז באינדקס 3 נתרסק עם השגיאה " java.lang.ArrayIndexOutOfBoundsException ". כפי שאולי ניחשתם, זה עבד לפני פשוט כי ההגדלה נקראת לאחר השלמת האיטרציה. כאשר העבירו את הביטוי הזה לאיטרציה, הכל נשבר. כפי שמתברר, אפילו בלולאה פשוטה אתה יכול לעשות בלגן) אם יש לך מערך, אולי יש דרך קלה יותר להציג את כל האלמנטים?
For and For-Each Loop: סיפור על איך חזרתי, חזרתי, אבל לא חזרתי - 3

לכל לולאה

החל מ-Java 1.5, מפתחי Java נתנו לנו עיצוב for each loopהמתואר באתר Oracle במדריך בשם " The For-Each Loop " או עבור גרסה 1.5.0 . באופן כללי, זה ייראה כך:
For and For-Each Loop: סיפור על איך חזרתי, חזרתי, אבל לא חזרתי - 4
אתה יכול לקרוא את התיאור של המבנה הזה במפרט שפת Java (JLS) כדי לוודא שזה לא קסם. בנייה זו מתוארת בפרק " 14.14.2. ההצהרה המשופרת ". כפי שאתה יכול לראות, ניתן להשתמש בלולאה עבור כל עם מערכים וכאלה שמיישמים את ממשק java.lang.Iterable . כלומר, אם אתה באמת רוצה, אתה יכול ליישם את הממשק java.lang.Iterable ועבור כל לולאה ניתן להשתמש עם הכיתה שלך. אתה תגיד מיד, "בסדר, זה אובייקט שניתן לחזור עליו, אבל מערך הוא לא אובייקט. בערך." ואתה תטעה, כי... ב-Java, מערכים הם אובייקטים שנוצרו באופן דינמי. מפרט השפה אומר לנו את זה: " בשפת התכנות Java, מערכים הם אובייקטים ." באופן כללי, מערכים הם קצת קסם JVM, כי... האופן שבו המערך בנוי באופן פנימי אינו ידוע והוא ממוקם איפשהו בתוך ה-Java Virtual Machine. כל מי שמתעניין יכול לקרוא את התשובות ב-stackoverflow: " כיצד עובד מחלקת מערך ב-Java? " מסתבר שאם אנחנו לא משתמשים במערך, אז אנחנו חייבים להשתמש במשהו שמיישם את Iterable . לדוגמה:
List<String> names = Arrays.asList("John", "Sara", "Jack");
for (String name : names) {
	System.out.println("Name = " + name);
}
כאן אתה יכול פשוט לזכור שאם אנחנו משתמשים באוספים ( java.util.Collection ), בזכות זה נקבל בדיוק Iterable . אם לאובייקט יש מחלקה שמיישמת Iterable, הוא מחויב לספק, כאשר קוראים לשיטת האיטרטור, איטרטור שיחזר על התוכן של אותו אובייקט. לקוד שלמעלה, למשל, יהיה קוד בייט משהו כזה (ב-IntelliJ Idea אתה יכול לעשות "View" -> "Show bytecode" :
For and For-Each Loop: סיפור על איך חזרתי, חזרתי, אבל לא חזרתי - 5
כפי שאתה יכול לראות, למעשה נעשה שימוש באיטרטור. אם זה לא היה עבור כל לולאה , היינו צריכים לכתוב משהו כמו:
List<String> names = Arrays.asList("John", "Sara", "Jack");
for (Iterator i = names.iterator(); /* continue if */ i.hasNext(); /* skip increment */) {
	String name = (String) i.next();
	System.out.println("Name = " + name);
}

איטרטור

כפי שראינו לעיל, ממשק Iterable אומר שבמקרים של אובייקט כלשהו, ​​אתה יכול לקבל איטרטור שבאמצעותו אתה יכול לחזור על התוכן. שוב, ניתן לומר שזהו עקרון האחריות היחידה של SOLID . מבנה הנתונים עצמו לא אמור להניע את המעבר, אבל הוא יכול לספק אחד שכן. היישום הבסיסי של Iterator הוא שבדרך כלל הוא מוכרז כמחלקה פנימית שיש לה גישה לתוכן של המחלקה החיצונית ומספקת את האלמנט הרצוי הכלול במחלקה החיצונית. הנה דוגמה מהמחלקה ArrayListלאופן שבו איטרטור מחזיר אלמנט:
public E next() {
            checkForComodification();
            int i = cursor;
            if (i >= size)
                throw new NoSuchElementException();
            Object[] elementData = ArrayList.this.elementData;
            if (i >= elementData.length)
                throw new ConcurrentModificationException();
            cursor = i + 1;
            return (E) elementData[lastRet = i];
}
כפי שאנו יכולים לראות, בעזרת ArrayList.thisאיטרטור ניגש למחלקה החיצונית ולמשתנה שלה elementData, ואז מחזיר אלמנט משם. אז, השגת איטרטור היא פשוטה מאוד:
List<String> names = Arrays.asList("John", "Sara", "Jack");
Iterator<String> iterator = names.iterator();
העבודה שלו מסתכמת בכך שאנחנו יכולים לבדוק אם יש אלמנטים בהמשך (מתודה hasNext ), לקבל את האלמנט הבא ( השיטה הבאה ) ואת המתודה remove , שמסירת את האלמנט האחרון שהתקבל דרך next . שיטת ההסרה היא אופציונלית ואינה מובטחת שתיושם. למעשה, ככל ש-Java מתפתחת, גם ממשקים מתפתחים. לכן, ב-Java 8 הייתה גם שיטה forEachRemainingהמאפשרת לבצע פעולה כלשהי על האלמנטים הנותרים שלא ביקרו על ידי האיטרטור. מה מעניין באיטרטור ובאוספים? לדוגמה, יש מחלקה AbstractList. זוהי מחלקה מופשטת שהיא האב של ArrayListו LinkedList. וזה מעניין אותנו בגלל תחום כמו modCount . בכל שינוי תוכן הרשימה משתנה. אז מה זה משנה לנו? והעובדה שהאיטרטור מוודא שבמהלך הפעולה לא משתנה האיסוף שמעליו הוא עובר. כפי שאתה מבין, היישום של האיטרטור עבור רשימות ממוקם באותו מקום כמו modcount , כלומר במחלקה AbstractList. בואו נסתכל על דוגמה פשוטה:
List<String> names = Arrays.asList("John", "Sara", "Jack");
names = new ArrayList(names);
Iterator<String> iterator = names.iterator();
names.add("modcount++");
System.out.println(iterator.next());
הנה הדבר המעניין הראשון, אם כי לא בנושא. למעשה Arrays.asListמחזיר אחד מיוחד משלו ArrayList( java.util.Arrays.ArrayList ). הוא אינו מיישם שיטות הוספה, ולכן אינו ניתן לשינוי. כתוב על זה ב-JavaDoc: גודל קבוע . אבל למעשה, זה יותר מגודל קבוע . הוא גם בלתי ניתן לשינוי , כלומר, בלתי ניתן לשינוי; להסיר גם לא יעבוד על זה. נקבל גם שגיאה, כי... לאחר שיצרנו את האיטרטור, נזכרנו ב-modcount בו . לאחר מכן שינינו את מצב האוסף "בחוץ" (כלומר, לא דרך האיטרטור) והפעלנו את שיטת האיטרטור. לכן, אנו מקבלים את השגיאה: java.util.ConcurrentModificationException . כדי להימנע מכך, השינוי במהלך האיטרציה חייב להתבצע דרך האיטרטור עצמו, ולא דרך גישה לאוסף:
List<String> names = Arrays.asList("John", "Sara", "Jack");
names = new ArrayList(names);
Iterator<String> iterator = names.iterator();
iterator.next();
iterator.remove();
System.out.println(iterator.next());
כפי שאתה מבין, אם iterator.remove()לא תעשה את זה לפני כן iterator.next(), אז בגלל. האיטרטור לא מצביע על שום אלמנט, אז נקבל שגיאה. בדוגמה, האיטרטור יעבור לאלמנט John , יסיר אותו ואז יקבל את האלמנט Sara . וכאן הכל יהיה בסדר, אבל מזל רע, שוב יש "ניואנסים") java.util.ConcurrentModificationException יתרחש רק כאשר hasNext()הוא מחזיר true . כלומר, אם תמחק את האלמנט האחרון דרך האוסף עצמו, האיטרטור לא ייפול. לפרטים נוספים, עדיף לצפות בדוח על פאזלים של ג'אווה מתוך " #ITsubbotnik מדור JAVA: פאזלים של ג'אווה ". התחלנו שיחה כל כך מפורטת מהסיבה הפשוטה שדווקא אותם ניואנסים חלים כאשר for each loop... האיטרטור האהוב עלינו משמש מתחת למכסה המנוע. וכל הניואנסים האלה חלים גם שם. הדבר היחיד הוא שלא תהיה לנו גישה לאיטרטור, ולא נוכל להסיר בבטחה את האלמנט. אגב, כפי שאתה מבין, המצב נזכר ברגע שבו האיטרטור נוצר. ומחיקה מאובטחת פועלת רק היכן שהיא נקראת. כלומר, אפשרות זו לא תעבוד:
Iterator<String> iterator1 = names.iterator();
Iterator<String> iterator2 = names.iterator();
iterator1.next();
iterator1.remove();
System.out.println(iterator2.next());
כי עבור iterator2 המחיקה דרך iterator1 הייתה "חיצונית", כלומר היא בוצעה איפשהו בחוץ והוא לא יודע עליה כלום. בנושא האיטרטורים, אני רוצה גם לציין זאת. איטרטור מיוחד ומורחב נוצר במיוחד עבור יישומי ממשק List. והם קראו לו ListIterator. זה מאפשר לך לנוע לא רק קדימה, אלא גם אחורה, וגם מאפשר לך לגלות את האינדקס של האלמנט הקודם והבא. בנוסף, זה מאפשר לך להחליף את האלמנט הנוכחי או להכניס אחד חדש במיקום בין מיקום האיטרטור הנוכחי לזה הבא. כפי שניחשתם, ListIteratorמותר לעשות זאת מכיוון Listשהגישה לפי אינדקס מיושמת.
For and For-Each Loop: סיפור על איך חזרתי, חזרתי, אבל לא חזרתי - 6

Java 8 ואיטרציה

שחרורו של Java 8 הפך את החיים לקלים יותר עבור רבים. גם לא התעלמנו מאיטרציה על תוכן של אובייקטים. כדי להבין איך זה עובד, אתה צריך לומר כמה מילים על זה. Java 8 הציגה את מחלקת java.util.function.Consumer . הנה דוגמה:
Consumer consumer = new Consumer() {
	@Override
	public void accept(Object o) {
		System.out.println(o);
	}
};
Consumer הוא ממשק פונקציונלי, מה שאומר שבתוך הממשק יש רק שיטה אבסטרקטית אחת לא מיושמת שדורשת הטמעה חובה באותן מחלקות שמציינות את המימושים של ממשק זה. זה מאפשר לך להשתמש בדבר קסום כמו למבדה. המאמר הזה לא עוסק בזה, אבל אנחנו צריכים להבין למה אנחנו יכולים להשתמש בו. אז, באמצעות lambdas, ניתן לשכתב את ה- Consumer שלמעלה כך: Consumer consumer = (obj) -> System.out.println(obj); זה אומר ש-Java רואה שמשהו שנקרא obj יועבר לקלט, ואז הביטוי שאחרי -> יבוצע עבור obj זה. לגבי איטרציה, כעת נוכל לעשות זאת:
List<String> names = Arrays.asList("John", "Sara", "Jack");
Consumer consumer = (obj) -> System.out.println(obj);
names.forEach(consumer);
אם תלך לשיטה forEachתראה שהכל פשוט בטירוף. הנה האהוב עלינו for-each loop:
default void forEach(Consumer<? super T> action) {
        Objects.requireNonNull(action);
        for (T t : this) {
            action.accept(t);
        }
}
אפשר גם להסיר אלמנט בצורה יפה באמצעות איטרטור, למשל:
List<String> names = Arrays.asList("John", "Sara", "Jack");
names = new ArrayList(names);
Predicate predicate = (obj) -> obj.equals("John");
names.removeIf(predicate);
במקרה זה, שיטת removeIf מקבלת כקלט לא Consumer , אלא Predicate . זה מחזיר בוליאני . במקרה זה, אם הפרדיקט אומר " true ", אז האלמנט יוסר. מעניין שגם כאן לא הכל ברור)) נו, מה אתה רוצה? צריך לתת לאנשים מקום ליצור חידות בכנס. לדוגמה, ניקח את הקוד הבא למחיקת כל מה שהאיטרטור יכול להגיע אליו לאחר איטרציה מסוימת:
List<String> names = Arrays.asList("John", "Sara", "Jack");
names = new ArrayList(names);
Iterator<String> iterator = names.iterator();
iterator.next(); // Курсор на John
while (iterator.hasNext()) {
    iterator.next(); // Следующий элемент
    iterator.remove(); // Удалor его
}
System.out.println(names);
בסדר, הכל עובד כאן. אבל אנחנו זוכרים ש-Java 8 אחרי הכל. לכן, בואו ננסה לפשט את הקוד:
List<String> names = Arrays.asList("John", "Sara", "Jack");
names = new ArrayList(names);
Iterator<String> iterator = names.iterator();
iterator.next(); // Курсор на John
iterator.forEachRemaining(obj -> iterator.remove());
System.out.println(names);
האם זה באמת נעשה יפה יותר? עם זאת, יהיה java.lang.IllegalStateException . והסיבה היא... באג בג'אווה. מסתבר שזה קבוע, אבל ב-JDK 9. הנה קישור למשימה ב-OpenJDK: Iterator.forEachRemaining vs. Iterator.remove . באופן טבעי, זה כבר נדון: מדוע iterator.forEachRemaining אינו מסיר את האלמנט מה-Consumer lambda? ובכן, דרך נוספת היא ישירות דרך ה-API של Stream:
List<String> names = new ArrayList(Arrays.asList("John", "Sara", "Jack"));
Stream<String> stream = names.stream();
stream.forEach(obj -> System.out.println(obj));

מסקנות

כפי שראינו מכל החומר לעיל, לולאה for-each loopהיא רק "סוכר תחבירי" על גבי איטרטור. עם זאת, הוא משמש כיום במקומות רבים. בנוסף, יש להשתמש בכל מוצר בזהירות. לדוגמה, אדם לא מזיק forEachRemainingעלול להסתיר הפתעות לא נעימות. וזה שוב מוכיח שיש צורך בבדיקות יחידה. בדיקה טובה יכולה לזהות מקרה שימוש כזה בקוד שלך. מה אתה יכול לצפות/לקרוא בנושא: #ויאצ'סלב
הערות
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION