1 /* 2 * Licensed to the Apache Software Foundation (ASF) under one or more 3 * contributor license agreements. See the NOTICE file distributed with 4 * this work for additional information regarding copyright ownership. 5 * The ASF licenses this file to You under the Apache License, Version 2.0 6 * (the "License"); you may not use this file except in compliance with 7 * the License. You may obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 */ 17 package org.apache.commons.lang3.time; 18 19 import java.text.SimpleDateFormat; 20 import java.util.ArrayList; 21 import java.util.Calendar; 22 import java.util.Date; 23 import java.util.GregorianCalendar; 24 import java.util.TimeZone; 25 import java.util.stream.Stream; 26 27 import org.apache.commons.lang3.StringUtils; 28 import org.apache.commons.lang3.Validate; 29 30 /** 31 * Duration formatting utilities and constants. The following table describes the tokens 32 * used in the pattern language for formatting. 33 * <table border="1"> 34 * <caption>Pattern Tokens</caption> 35 * <tr><th>character</th><th>duration element</th></tr> 36 * <tr><td>y</td><td>years</td></tr> 37 * <tr><td>M</td><td>months</td></tr> 38 * <tr><td>d</td><td>days</td></tr> 39 * <tr><td>H</td><td>hours</td></tr> 40 * <tr><td>m</td><td>minutes</td></tr> 41 * <tr><td>s</td><td>seconds</td></tr> 42 * <tr><td>S</td><td>milliseconds</td></tr> 43 * <tr><td>'text'</td><td>arbitrary text content</td></tr> 44 * </table> 45 * 46 * <b>Note: It's not currently possible to include a single-quote in a format.</b> 47 * <br> 48 * Token values are printed using decimal digits. 49 * A token character can be repeated to ensure that the field occupies a certain minimum 50 * size. Values will be left-padded with 0 unless padding is disabled in the method invocation. 51 * @since 2.1 52 */ 53 public class DurationFormatUtils { 54 55 /** 56 * DurationFormatUtils instances should NOT be constructed in standard programming. 57 * 58 * <p>This constructor is public to permit tools that require a JavaBean instance 59 * to operate.</p> 60 */ DurationFormatUtils()61 public DurationFormatUtils() { 62 } 63 64 /** 65 * Pattern used with {@link FastDateFormat} and {@link SimpleDateFormat} 66 * for the ISO 8601 period format used in durations. 67 * 68 * @see org.apache.commons.lang3.time.FastDateFormat 69 * @see java.text.SimpleDateFormat 70 */ 71 public static final String ISO_EXTENDED_FORMAT_PATTERN = "'P'yyyy'Y'M'M'd'DT'H'H'm'M's.SSS'S'"; 72 73 /** 74 * Formats the time gap as a string. 75 * 76 * <p>The format used is ISO 8601-like: {@code HH:mm:ss.SSS}.</p> 77 * 78 * @param durationMillis the duration to format 79 * @return the formatted duration, not null 80 * @throws IllegalArgumentException if durationMillis is negative 81 */ formatDurationHMS(final long durationMillis)82 public static String formatDurationHMS(final long durationMillis) { 83 return formatDuration(durationMillis, "HH:mm:ss.SSS"); 84 } 85 86 /** 87 * Formats the time gap as a string. 88 * 89 * <p>The format used is the ISO 8601 period format.</p> 90 * 91 * <p>This method formats durations using the days and lower fields of the 92 * ISO format pattern, such as P7D6TH5M4.321S.</p> 93 * 94 * @param durationMillis the duration to format 95 * @return the formatted duration, not null 96 * @throws IllegalArgumentException if durationMillis is negative 97 */ formatDurationISO(final long durationMillis)98 public static String formatDurationISO(final long durationMillis) { 99 return formatDuration(durationMillis, ISO_EXTENDED_FORMAT_PATTERN, false); 100 } 101 102 /** 103 * Formats the time gap as a string, using the specified format, and padding with zeros. 104 * 105 * <p>This method formats durations using the days and lower fields of the 106 * format pattern. Months and larger are not used.</p> 107 * 108 * @param durationMillis the duration to format 109 * @param format the way in which to format the duration, not null 110 * @return the formatted duration, not null 111 * @throws IllegalArgumentException if durationMillis is negative 112 */ formatDuration(final long durationMillis, final String format)113 public static String formatDuration(final long durationMillis, final String format) { 114 return formatDuration(durationMillis, format, true); 115 } 116 117 /** 118 * Formats the time gap as a string, using the specified format. 119 * Padding the left-hand side of numbers with zeroes is optional. 120 * 121 * <p>This method formats durations using the days and lower fields of the 122 * format pattern. Months and larger are not used.</p> 123 * 124 * @param durationMillis the duration to format 125 * @param format the way in which to format the duration, not null 126 * @param padWithZeros whether to pad the left-hand side of numbers with 0's 127 * @return the formatted duration, not null 128 * @throws IllegalArgumentException if durationMillis is negative 129 */ formatDuration(final long durationMillis, final String format, final boolean padWithZeros)130 public static String formatDuration(final long durationMillis, final String format, final boolean padWithZeros) { 131 Validate.inclusiveBetween(0, Long.MAX_VALUE, durationMillis, "durationMillis must not be negative"); 132 133 final Token[] tokens = lexx(format); 134 135 long days = 0; 136 long hours = 0; 137 long minutes = 0; 138 long seconds = 0; 139 long milliseconds = durationMillis; 140 141 if (Token.containsTokenWithValue(tokens, d)) { 142 days = milliseconds / DateUtils.MILLIS_PER_DAY; 143 milliseconds = milliseconds - (days * DateUtils.MILLIS_PER_DAY); 144 } 145 if (Token.containsTokenWithValue(tokens, H)) { 146 hours = milliseconds / DateUtils.MILLIS_PER_HOUR; 147 milliseconds = milliseconds - (hours * DateUtils.MILLIS_PER_HOUR); 148 } 149 if (Token.containsTokenWithValue(tokens, m)) { 150 minutes = milliseconds / DateUtils.MILLIS_PER_MINUTE; 151 milliseconds = milliseconds - (minutes * DateUtils.MILLIS_PER_MINUTE); 152 } 153 if (Token.containsTokenWithValue(tokens, s)) { 154 seconds = milliseconds / DateUtils.MILLIS_PER_SECOND; 155 milliseconds = milliseconds - (seconds * DateUtils.MILLIS_PER_SECOND); 156 } 157 158 return format(tokens, 0, 0, days, hours, minutes, seconds, milliseconds, padWithZeros); 159 } 160 161 /** 162 * Formats an elapsed time into a pluralization correct string. 163 * 164 * <p>This method formats durations using the days and lower fields of the 165 * format pattern. Months and larger are not used.</p> 166 * 167 * @param durationMillis the elapsed time to report in milliseconds 168 * @param suppressLeadingZeroElements suppresses leading 0 elements 169 * @param suppressTrailingZeroElements suppresses trailing 0 elements 170 * @return the formatted text in days/hours/minutes/seconds, not null 171 * @throws IllegalArgumentException if durationMillis is negative 172 */ formatDurationWords( final long durationMillis, final boolean suppressLeadingZeroElements, final boolean suppressTrailingZeroElements)173 public static String formatDurationWords( 174 final long durationMillis, 175 final boolean suppressLeadingZeroElements, 176 final boolean suppressTrailingZeroElements) { 177 178 // This method is generally replaceable by the format method, but 179 // there are a series of tweaks and special cases that require 180 // trickery to replicate. 181 String duration = formatDuration(durationMillis, "d' days 'H' hours 'm' minutes 's' seconds'"); 182 if (suppressLeadingZeroElements) { 183 // this is a temporary marker on the front. Like ^ in regexp. 184 duration = " " + duration; 185 String tmp = StringUtils.replaceOnce(duration, " 0 days", StringUtils.EMPTY); 186 if (tmp.length() != duration.length()) { 187 duration = tmp; 188 tmp = StringUtils.replaceOnce(duration, " 0 hours", StringUtils.EMPTY); 189 if (tmp.length() != duration.length()) { 190 duration = tmp; 191 tmp = StringUtils.replaceOnce(duration, " 0 minutes", StringUtils.EMPTY); 192 duration = tmp; 193 if (tmp.length() != duration.length()) { 194 duration = StringUtils.replaceOnce(tmp, " 0 seconds", StringUtils.EMPTY); 195 } 196 } 197 } 198 if (!duration.isEmpty()) { 199 // strip the space off again 200 duration = duration.substring(1); 201 } 202 } 203 if (suppressTrailingZeroElements) { 204 String tmp = StringUtils.replaceOnce(duration, " 0 seconds", StringUtils.EMPTY); 205 if (tmp.length() != duration.length()) { 206 duration = tmp; 207 tmp = StringUtils.replaceOnce(duration, " 0 minutes", StringUtils.EMPTY); 208 if (tmp.length() != duration.length()) { 209 duration = tmp; 210 tmp = StringUtils.replaceOnce(duration, " 0 hours", StringUtils.EMPTY); 211 if (tmp.length() != duration.length()) { 212 duration = StringUtils.replaceOnce(tmp, " 0 days", StringUtils.EMPTY); 213 } 214 } 215 } 216 } 217 // handle plurals 218 duration = " " + duration; 219 duration = StringUtils.replaceOnce(duration, " 1 seconds", " 1 second"); 220 duration = StringUtils.replaceOnce(duration, " 1 minutes", " 1 minute"); 221 duration = StringUtils.replaceOnce(duration, " 1 hours", " 1 hour"); 222 duration = StringUtils.replaceOnce(duration, " 1 days", " 1 day"); 223 return duration.trim(); 224 } 225 226 /** 227 * Formats the time gap as a string. 228 * 229 * <p>The format used is the ISO 8601 period format.</p> 230 * 231 * @param startMillis the start of the duration to format 232 * @param endMillis the end of the duration to format 233 * @return the formatted duration, not null 234 * @throws IllegalArgumentException if startMillis is greater than endMillis 235 */ formatPeriodISO(final long startMillis, final long endMillis)236 public static String formatPeriodISO(final long startMillis, final long endMillis) { 237 return formatPeriod(startMillis, endMillis, ISO_EXTENDED_FORMAT_PATTERN, false, TimeZone.getDefault()); 238 } 239 240 /** 241 * Formats the time gap as a string, using the specified format. 242 * Padding the left-hand side of numbers with zeroes is optional. 243 * 244 * @param startMillis the start of the duration 245 * @param endMillis the end of the duration 246 * @param format the way in which to format the duration, not null 247 * @return the formatted duration, not null 248 * @throws IllegalArgumentException if startMillis is greater than endMillis 249 */ formatPeriod(final long startMillis, final long endMillis, final String format)250 public static String formatPeriod(final long startMillis, final long endMillis, final String format) { 251 return formatPeriod(startMillis, endMillis, format, true, TimeZone.getDefault()); 252 } 253 254 /** 255 * <p>Formats the time gap as a string, using the specified format. 256 * Padding the left-hand side of numbers with zeroes is optional and 257 * the time zone may be specified. 258 * 259 * <p>When calculating the difference between months/days, it chooses to 260 * calculate months first. So when working out the number of months and 261 * days between January 15th and March 10th, it choose 1 month and 262 * 23 days gained by choosing January->February = 1 month and then 263 * calculating days forwards, and not the 1 month and 26 days gained by 264 * choosing March -> February = 1 month and then calculating days 265 * backwards.</p> 266 * 267 * <p>For more control, the <a href="https://www.joda.org/joda-time/">Joda-Time</a> 268 * library is recommended.</p> 269 * 270 * @param startMillis the start of the duration 271 * @param endMillis the end of the duration 272 * @param format the way in which to format the duration, not null 273 * @param padWithZeros whether to pad the left-hand side of numbers with 0's 274 * @param timezone the millis are defined in 275 * @return the formatted duration, not null 276 * @throws IllegalArgumentException if startMillis is greater than endMillis 277 */ formatPeriod(final long startMillis, final long endMillis, final String format, final boolean padWithZeros, final TimeZone timezone)278 public static String formatPeriod(final long startMillis, final long endMillis, final String format, final boolean padWithZeros, 279 final TimeZone timezone) { 280 Validate.isTrue(startMillis <= endMillis, "startMillis must not be greater than endMillis"); 281 282 283 // Used to optimise for differences under 28 days and 284 // called formatDuration(millis, format); however this did not work 285 // over leap years. 286 // TODO: Compare performance to see if anything was lost by 287 // losing this optimisation. 288 289 final Token[] tokens = lexx(format); 290 291 // time zones get funky around 0, so normalizing everything to GMT 292 // stops the hours being off 293 final Calendar start = Calendar.getInstance(timezone); 294 start.setTime(new Date(startMillis)); 295 final Calendar end = Calendar.getInstance(timezone); 296 end.setTime(new Date(endMillis)); 297 298 // initial estimates 299 int milliseconds = end.get(Calendar.MILLISECOND) - start.get(Calendar.MILLISECOND); 300 int seconds = end.get(Calendar.SECOND) - start.get(Calendar.SECOND); 301 int minutes = end.get(Calendar.MINUTE) - start.get(Calendar.MINUTE); 302 int hours = end.get(Calendar.HOUR_OF_DAY) - start.get(Calendar.HOUR_OF_DAY); 303 int days = end.get(Calendar.DAY_OF_MONTH) - start.get(Calendar.DAY_OF_MONTH); 304 int months = end.get(Calendar.MONTH) - start.get(Calendar.MONTH); 305 int years = end.get(Calendar.YEAR) - start.get(Calendar.YEAR); 306 307 // each initial estimate is adjusted in case it is under 0 308 while (milliseconds < 0) { 309 milliseconds += 1000; 310 seconds -= 1; 311 } 312 while (seconds < 0) { 313 seconds += 60; 314 minutes -= 1; 315 } 316 while (minutes < 0) { 317 minutes += 60; 318 hours -= 1; 319 } 320 while (hours < 0) { 321 hours += 24; 322 days -= 1; 323 } 324 325 if (Token.containsTokenWithValue(tokens, M)) { 326 while (days < 0) { 327 days += start.getActualMaximum(Calendar.DAY_OF_MONTH); 328 months -= 1; 329 start.add(Calendar.MONTH, 1); 330 } 331 332 while (months < 0) { 333 months += 12; 334 years -= 1; 335 } 336 337 if (!Token.containsTokenWithValue(tokens, y) && years != 0) { 338 while (years != 0) { 339 months += 12 * years; 340 years = 0; 341 } 342 } 343 } else { 344 // there are no M's in the format string 345 346 if (!Token.containsTokenWithValue(tokens, y)) { 347 int target = end.get(Calendar.YEAR); 348 if (months < 0) { 349 // target is end-year -1 350 target -= 1; 351 } 352 353 while (start.get(Calendar.YEAR) != target) { 354 days += start.getActualMaximum(Calendar.DAY_OF_YEAR) - start.get(Calendar.DAY_OF_YEAR); 355 356 // Not sure I grok why this is needed, but the brutal tests show it is 357 if (start instanceof GregorianCalendar && 358 start.get(Calendar.MONTH) == Calendar.FEBRUARY && 359 start.get(Calendar.DAY_OF_MONTH) == 29) { 360 days += 1; 361 } 362 363 start.add(Calendar.YEAR, 1); 364 365 days += start.get(Calendar.DAY_OF_YEAR); 366 } 367 368 years = 0; 369 } 370 371 while (start.get(Calendar.MONTH) != end.get(Calendar.MONTH)) { 372 days += start.getActualMaximum(Calendar.DAY_OF_MONTH); 373 start.add(Calendar.MONTH, 1); 374 } 375 376 months = 0; 377 378 while (days < 0) { 379 days += start.getActualMaximum(Calendar.DAY_OF_MONTH); 380 months -= 1; 381 start.add(Calendar.MONTH, 1); 382 } 383 384 } 385 386 // The rest of this code adds in values that 387 // aren't requested. This allows the user to ask for the 388 // number of months and get the real count and not just 0->11. 389 390 if (!Token.containsTokenWithValue(tokens, d)) { 391 hours += 24 * days; 392 days = 0; 393 } 394 if (!Token.containsTokenWithValue(tokens, H)) { 395 minutes += 60 * hours; 396 hours = 0; 397 } 398 if (!Token.containsTokenWithValue(tokens, m)) { 399 seconds += 60 * minutes; 400 minutes = 0; 401 } 402 if (!Token.containsTokenWithValue(tokens, s)) { 403 milliseconds += 1000 * seconds; 404 seconds = 0; 405 } 406 407 return format(tokens, years, months, days, hours, minutes, seconds, milliseconds, padWithZeros); 408 } 409 410 /** 411 * The internal method to do the formatting. 412 * 413 * @param tokens the tokens 414 * @param years the number of years 415 * @param months the number of months 416 * @param days the number of days 417 * @param hours the number of hours 418 * @param minutes the number of minutes 419 * @param seconds the number of seconds 420 * @param milliseconds the number of millis 421 * @param padWithZeros whether to pad 422 * @return the formatted string 423 */ format(final Token[] tokens, final long years, final long months, final long days, final long hours, final long minutes, final long seconds, final long milliseconds, final boolean padWithZeros)424 static String format(final Token[] tokens, final long years, final long months, final long days, final long hours, final long minutes, final long seconds, 425 final long milliseconds, final boolean padWithZeros) { 426 final StringBuilder buffer = new StringBuilder(); 427 boolean lastOutputSeconds = false; 428 for (final Token token : tokens) { 429 final Object value = token.getValue(); 430 final int count = token.getCount(); 431 if (value instanceof StringBuilder) { 432 buffer.append(value.toString()); 433 } else if (value.equals(y)) { 434 buffer.append(paddedValue(years, padWithZeros, count)); 435 lastOutputSeconds = false; 436 } else if (value.equals(M)) { 437 buffer.append(paddedValue(months, padWithZeros, count)); 438 lastOutputSeconds = false; 439 } else if (value.equals(d)) { 440 buffer.append(paddedValue(days, padWithZeros, count)); 441 lastOutputSeconds = false; 442 } else if (value.equals(H)) { 443 buffer.append(paddedValue(hours, padWithZeros, count)); 444 lastOutputSeconds = false; 445 } else if (value.equals(m)) { 446 buffer.append(paddedValue(minutes, padWithZeros, count)); 447 lastOutputSeconds = false; 448 } else if (value.equals(s)) { 449 buffer.append(paddedValue(seconds, padWithZeros, count)); 450 lastOutputSeconds = true; 451 } else if (value.equals(S)) { 452 if (lastOutputSeconds) { 453 // ensure at least 3 digits are displayed even if padding is not selected 454 final int width = padWithZeros ? Math.max(3, count) : 3; 455 buffer.append(paddedValue(milliseconds, true, width)); 456 } else { 457 buffer.append(paddedValue(milliseconds, padWithZeros, count)); 458 } 459 lastOutputSeconds = false; 460 } 461 } 462 return buffer.toString(); 463 } 464 465 /** 466 * Converts a {@code long} to a {@link String} with optional 467 * zero padding. 468 * 469 * @param value the value to convert 470 * @param padWithZeros whether to pad with zeroes 471 * @param count the size to pad to (ignored if {@code padWithZeros} is false) 472 * @return the string result 473 */ paddedValue(final long value, final boolean padWithZeros, final int count)474 private static String paddedValue(final long value, final boolean padWithZeros, final int count) { 475 final String longString = Long.toString(value); 476 return padWithZeros ? StringUtils.leftPad(longString, count, '0') : longString; 477 } 478 479 static final String y = "y"; 480 static final String M = "M"; 481 static final String d = "d"; 482 static final String H = "H"; 483 static final String m = "m"; 484 static final String s = "s"; 485 static final String S = "S"; 486 487 /** 488 * Parses a classic date format string into Tokens 489 * 490 * @param format the format to parse, not null 491 * @return array of Token[] 492 */ lexx(final String format)493 static Token[] lexx(final String format) { 494 final ArrayList<Token> list = new ArrayList<>(format.length()); 495 496 boolean inLiteral = false; 497 // Although the buffer is stored in a Token, the Tokens are only 498 // used internally, so cannot be accessed by other threads 499 StringBuilder buffer = null; 500 Token previous = null; 501 for (int i = 0; i < format.length(); i++) { 502 final char ch = format.charAt(i); 503 if (inLiteral && ch != '\'') { 504 buffer.append(ch); // buffer can't be null if inLiteral is true 505 continue; 506 } 507 String value = null; 508 switch (ch) { 509 // TODO: Need to handle escaping of ' 510 case '\'': 511 if (inLiteral) { 512 buffer = null; 513 inLiteral = false; 514 } else { 515 buffer = new StringBuilder(); 516 list.add(new Token(buffer)); 517 inLiteral = true; 518 } 519 break; 520 case 'y': 521 value = y; 522 break; 523 case 'M': 524 value = M; 525 break; 526 case 'd': 527 value = d; 528 break; 529 case 'H': 530 value = H; 531 break; 532 case 'm': 533 value = m; 534 break; 535 case 's': 536 value = s; 537 break; 538 case 'S': 539 value = S; 540 break; 541 default: 542 if (buffer == null) { 543 buffer = new StringBuilder(); 544 list.add(new Token(buffer)); 545 } 546 buffer.append(ch); 547 } 548 549 if (value != null) { 550 if (previous != null && previous.getValue().equals(value)) { 551 previous.increment(); 552 } else { 553 final Token token = new Token(value); 554 list.add(token); 555 previous = token; 556 } 557 buffer = null; 558 } 559 } 560 if (inLiteral) { // i.e. we have not found the end of the literal 561 throw new IllegalArgumentException("Unmatched quote in format: " + format); 562 } 563 return list.toArray(Token.EMPTY_ARRAY); 564 } 565 566 /** 567 * Element that is parsed from the format pattern. 568 */ 569 static class Token { 570 571 /** Empty array. */ 572 private static final Token[] EMPTY_ARRAY = {}; 573 574 /** 575 * Helper method to determine if a set of tokens contain a value 576 * 577 * @param tokens set to look in 578 * @param value to look for 579 * @return boolean {@code true} if contained 580 */ containsTokenWithValue(final Token[] tokens, final Object value)581 static boolean containsTokenWithValue(final Token[] tokens, final Object value) { 582 return Stream.of(tokens).anyMatch(token -> token.getValue() == value); 583 } 584 585 private final Object value; 586 private int count; 587 588 /** 589 * Wraps a token around a value. A value would be something like a 'Y'. 590 * 591 * @param value to wrap 592 */ Token(final Object value)593 Token(final Object value) { 594 this.value = value; 595 this.count = 1; 596 } 597 598 /** 599 * Wraps a token around a repeated number of a value, for example it would 600 * store 'yyyy' as a value for y and a count of 4. 601 * 602 * @param value to wrap 603 * @param count to wrap 604 */ Token(final Object value, final int count)605 Token(final Object value, final int count) { 606 this.value = value; 607 this.count = count; 608 } 609 610 /** 611 * Adds another one of the value 612 */ increment()613 void increment() { 614 count++; 615 } 616 617 /** 618 * Gets the current number of values represented 619 * 620 * @return int number of values represented 621 */ getCount()622 int getCount() { 623 return count; 624 } 625 626 /** 627 * Gets the particular value this token represents. 628 * 629 * @return Object value 630 */ getValue()631 Object getValue() { 632 return value; 633 } 634 635 /** 636 * Supports equality of this Token to another Token. 637 * 638 * @param obj2 Object to consider equality of 639 * @return boolean {@code true} if equal 640 */ 641 @Override equals(final Object obj2)642 public boolean equals(final Object obj2) { 643 if (obj2 instanceof Token) { 644 final Token tok2 = (Token) obj2; 645 if (this.value.getClass() != tok2.value.getClass()) { 646 return false; 647 } 648 if (this.count != tok2.count) { 649 return false; 650 } 651 if (this.value instanceof StringBuilder) { 652 return this.value.toString().equals(tok2.value.toString()); 653 } 654 if (this.value instanceof Number) { 655 return this.value.equals(tok2.value); 656 } 657 return this.value == tok2.value; 658 } 659 return false; 660 } 661 662 /** 663 * Returns a hash code for the token equal to the 664 * hash code for the token's value. Thus 'TT' and 'TTTT' 665 * will have the same hash code. 666 * 667 * @return The hash code for the token 668 */ 669 @Override hashCode()670 public int hashCode() { 671 return this.value.hashCode(); 672 } 673 674 /** 675 * Represents this token as a String. 676 * 677 * @return String representation of the token 678 */ 679 @Override toString()680 public String toString() { 681 return StringUtils.repeat(this.value.toString(), this.count); 682 } 683 } 684 685 } 686