diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a58854b50a..2c90a47b64 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -13,12 +13,38 @@ New Features * util-core: Provide a way to listen for stream termination to `c.t.util.Reader`, `Reader#onClose` which is satisfied when the stream is discarded or read until the end. ``PHAB_ID=D236311`` +* util-core: Conversions in `c.t.conversions` have new implementations + that follow a naming scheme of `SomethingOps`. Where possible the implementations + are `AnyVal` based avoiding allocations for the common usage pattern. + ``PHAB_ID=D249403`` + + - `percent` is now `PercentOps` + - `storage` is now `StorageUnitOps` + - `string` is now `StringOps` + - `thread` is now `ThreadOps` + - `time` is now `DurationOps` + - `u64` is now `U64Ops` + Bug Fixes ~~~~~~~~~ * util-core: Fixed a bug where tail would sometimes return Some empty AsyncStream instead of None. ``PHAB_ID=D241513`` +Deprecations +~~~~~~~~~~~~ + +* util-core: Conversions in `c.t.conversions` have been deprecated in favor of `SomethingOps` + versions. Where possible the implementations are `AnyVal` based and use implicit classes + instead of implicit conversions. ``PHAB_ID=D249403`` + + - `percent` is now `PercentOps` + - `storage` is now `StorageUnitOps` + - `string` is now `StringOps` + - `thread` is now `ThreadOps` + - `time` is now `DurationOps` + - `u64` is now `U64Ops` + Breaking API Changes ~~~~~~~~~~~~~~~~~~~~ diff --git a/util-core/src/main/scala/com/twitter/conversions/DurationOps.scala b/util-core/src/main/scala/com/twitter/conversions/DurationOps.scala new file mode 100644 index 0000000000..a9cc5bc904 --- /dev/null +++ b/util-core/src/main/scala/com/twitter/conversions/DurationOps.scala @@ -0,0 +1,40 @@ +package com.twitter.conversions + +import com.twitter.util.Duration +import java.util.concurrent.TimeUnit + +/** + * Implicits for writing readable [[Duration]]s. + * + * @example + * {{{ + * import com.twitter.conversions.DurationOps._ + * + * 2000.nanoseconds + * 50.milliseconds + * 1.second + * 24.hours + * 40.days + * }}} + */ +object DurationOps { + + implicit class RichLong(val numNanos: Long) extends AnyVal { + def nanoseconds: Duration = Duration(numNanos, TimeUnit.NANOSECONDS) + def nanosecond: Duration = nanoseconds + def microseconds: Duration = Duration(numNanos, TimeUnit.MICROSECONDS) + def microsecond: Duration = microseconds + def milliseconds: Duration = Duration(numNanos, TimeUnit.MILLISECONDS) + def millisecond: Duration = milliseconds + def millis: Duration = milliseconds + def seconds: Duration = Duration(numNanos, TimeUnit.SECONDS) + def second: Duration = seconds + def minutes: Duration = Duration(numNanos, TimeUnit.MINUTES) + def minute: Duration = minutes + def hours: Duration = Duration(numNanos, TimeUnit.HOURS) + def hour: Duration = hours + def days: Duration = Duration(numNanos, TimeUnit.DAYS) + def day: Duration = days + } + +} diff --git a/util-core/src/main/scala/com/twitter/conversions/PercentOps.scala b/util-core/src/main/scala/com/twitter/conversions/PercentOps.scala new file mode 100644 index 0000000000..d916c958b8 --- /dev/null +++ b/util-core/src/main/scala/com/twitter/conversions/PercentOps.scala @@ -0,0 +1,33 @@ +package com.twitter.conversions + +/** + * Implicits for turning `x.percent` (where `x` is an `Int` or `Double`) into a `Double` + * scaled to where 1.0 is 100 percent. + * + * @note Negative values, fractional values, and values greater than 100 are permitted. + * + * @example + * {{{ + * import com.twitter.conversions.PercentOps._ + * + * 1.percent == 0.01 + * 100.percent == 1.0 + * 99.9.percent == 0.999 + * 500.percent == 5.0 + * -10.percent == -0.1 + * }}} + */ +object PercentOps { + + private val BigDecimal100 = BigDecimal(100.0) + + implicit class RichDouble(val value: Double) extends AnyVal { + // convert wrapped to BigDecimal to preserve precision when dividing Doubles + def percent: Double = + if (value.equals(Double.NaN) + || value.equals(Double.PositiveInfinity) + || value.equals(Double.NegativeInfinity)) value + else (BigDecimal(value) / BigDecimal100).doubleValue + } + +} diff --git a/util-core/src/main/scala/com/twitter/conversions/StorageUnitOps.scala b/util-core/src/main/scala/com/twitter/conversions/StorageUnitOps.scala new file mode 100644 index 0000000000..cadf77e6ae --- /dev/null +++ b/util-core/src/main/scala/com/twitter/conversions/StorageUnitOps.scala @@ -0,0 +1,37 @@ +package com.twitter.conversions + +import com.twitter.util.StorageUnit + +/** + * Implicits for writing readable [[StorageUnit]]s. + * + * @example + * {{{ + * import com.twitter.conversions.StorageUnitOps._ + * + * 5.bytes + * 1.kilobyte + * 256.gigabytes + * }}} + */ +object StorageUnitOps { + + implicit class RichLong(val numBytes: Long) extends AnyVal { + def byte: StorageUnit = bytes + def bytes: StorageUnit = StorageUnit.fromBytes(numBytes) + def kilobyte: StorageUnit = kilobytes + def kilobytes: StorageUnit = StorageUnit.fromKilobytes(numBytes) + def megabyte: StorageUnit = megabytes + def megabytes: StorageUnit = StorageUnit.fromMegabytes(numBytes) + def gigabyte: StorageUnit = gigabytes + def gigabytes: StorageUnit = StorageUnit.fromGigabytes(numBytes) + def terabyte: StorageUnit = terabytes + def terabytes: StorageUnit = StorageUnit.fromTerabytes(numBytes) + def petabyte: StorageUnit = petabytes + def petabytes: StorageUnit = StorageUnit.fromPetabytes(numBytes) + + def thousand: Long = numBytes * 1000 + def million: Long = numBytes * 1000 * 1000 + def billion: Long = numBytes * 1000 * 1000 * 1000 + } +} diff --git a/util-core/src/main/scala/com/twitter/conversions/StringOps.scala b/util-core/src/main/scala/com/twitter/conversions/StringOps.scala new file mode 100644 index 0000000000..fd9e7dedc5 --- /dev/null +++ b/util-core/src/main/scala/com/twitter/conversions/StringOps.scala @@ -0,0 +1,144 @@ +package com.twitter.conversions + +import scala.util.matching.Regex + +object StringOps { + + // we intentionally don't unquote "\$" here, so it can be used to escape interpolation later. + private val UNQUOTE_RE = """\\(u[\dA-Fa-f]{4}|x[\dA-Fa-f]{2}|[/rnt\"\\])""".r + + private val QUOTE_RE = "[\u0000-\u001f\u007f-\uffff\\\\\"]".r + + implicit final class RichString(val string: String) extends AnyVal { + + /** + * For every section of a string that matches a regular expression, call + * a function to determine a replacement (as in python's + * `re.sub`). The function will be passed the Matcher object + * corresponding to the substring that matches the pattern, and that + * substring will be replaced by the function's result. + * + * For example, this call: + * {{{ + * "ohio".regexSub("""h.""".r) { m => "n" } + * }}} + * will return the string `"ono"`. + * + * The matches are found using `Matcher.find()` and so + * will obey all the normal java rules (the matches will not overlap, + * etc). + * + * @param re the regex pattern to replace + * @param replace a function that takes Regex.MatchData objects and + * returns a string to substitute + * @return the resulting string with replacements made + */ + def regexSub(re: Regex)(replace: Regex.MatchData => String): String = { + var offset = 0 + val out = new StringBuilder() + + for (m <- re.findAllIn(string).matchData) { + if (m.start > offset) { + out.append(string.substring(offset, m.start)) + } + + out.append(replace(m)) + offset = m.end + } + + if (offset < string.length) { + out.append(string.substring(offset)) + } + out.toString + } + + /** + * Quote a string so that unprintable chars (in ASCII) are represented by + * C-style backslash expressions. For example, a raw linefeed will be + * translated into `"\n"`. Control codes (anything below 0x20) + * and unprintables (anything above 0x7E) are turned into either + * `"\xHH"` or `"\\uHHHH"` expressions, depending on + * their range. Embedded backslashes and double-quotes are also quoted. + * + * @return a quoted string, suitable for ASCII display + */ + def quoteC(): String = { + regexSub(QUOTE_RE) { m => + m.matched.charAt(0) match { + case '\r' => "\\r" + case '\n' => "\\n" + case '\t' => "\\t" + case '"' => "\\\"" + case '\\' => "\\\\" + case c => + if (c <= 255) { + "\\x%02x".format(c.asInstanceOf[Int]) + } else { + "\\u%04x" format c.asInstanceOf[Int] + } + } + } + } + + /** + * Unquote an ASCII string that has been quoted in a style like + * [[quoteC()]] and convert it into a standard unicode string. + * `"\\uHHHH"` and `"\xHH"` expressions are unpacked + * into unicode characters, as well as `"\r"`, `"\n"`, + * `"\t"`, `"\\"`, and `'\"'`. + * + * @return an unquoted unicode string + */ + def unquoteC(): String = { + regexSub(UNQUOTE_RE) { m => + val ch = m.group(1).charAt(0) match { + // holy crap! this is terrible: + case 'u' => + Character.valueOf(Integer.valueOf(m.group(1).substring(1), 16).asInstanceOf[Int].toChar) + case 'x' => + Character.valueOf(Integer.valueOf(m.group(1).substring(1), 16).asInstanceOf[Int].toChar) + case 'r' => '\r' + case 'n' => '\n' + case 't' => '\t' + case x => x + } + ch.toString + } + } + + /** + * Turn a string of hex digits into a byte array. This does the exact + * opposite of `Array[Byte]#hexlify`. + */ + def unhexlify(): Array[Byte] = { + val buffer = new Array[Byte]((string.length + 1) / 2) + string.grouped(2).toSeq.zipWithIndex.foreach { + case (substr, i) => + buffer(i) = Integer.parseInt(substr, 16).toByte + } + buffer + } + } + + def hexlify(array: Array[Byte], from: Int, to: Int): String = { + val out = new StringBuffer + for (i <- from until to) { + val b = array(i) + val s = (b.toInt & 0xff).toHexString + if (s.length < 2) { + out.append('0') + } + out.append(s) + } + out.toString + } + + implicit final class RichByteArray(val bytes: Array[Byte]) extends AnyVal { + + /** + * Turn an `Array[Byte]` into a string of hex digits. + */ + def hexlify: String = StringOps.hexlify(bytes, 0, bytes.length) + } + +} diff --git a/util-core/src/main/scala/com/twitter/conversions/ThreadOps.scala b/util-core/src/main/scala/com/twitter/conversions/ThreadOps.scala new file mode 100644 index 0000000000..a6589c4c78 --- /dev/null +++ b/util-core/src/main/scala/com/twitter/conversions/ThreadOps.scala @@ -0,0 +1,19 @@ +package com.twitter.conversions + +import java.util.concurrent.Callable +import scala.language.implicitConversions + +/** + * Implicits for turning a block of code into a Runnable or Callable. + */ +object ThreadOps { + + implicit def makeRunnable(f: => Unit): Runnable = new Runnable() { + def run(): Unit = f + } + + implicit def makeCallable[T](f: => T): Callable[T] = new Callable[T]() { + def call(): T = f + } + +} diff --git a/util-core/src/main/scala/com/twitter/conversions/U64Ops.scala b/util-core/src/main/scala/com/twitter/conversions/U64Ops.scala new file mode 100644 index 0000000000..07f6a3a1c6 --- /dev/null +++ b/util-core/src/main/scala/com/twitter/conversions/U64Ops.scala @@ -0,0 +1,22 @@ +package com.twitter.conversions + +object U64Ops { + + /** + * Parses this HEX string as an unsigned 64-bit long value. Be careful, this can throw + * [[NumberFormatException]]. + * + * @see [[java.lang.Long.parseUnsignedLong()]] + */ + implicit class StringOps(val self: String) extends AnyVal { + def toU64Long: Long = java.lang.Long.parseUnsignedLong(self, 16) + } + + /** + * Converts this unsigned 64-bit long value into a 16-character HEX string. + */ + implicit class LongOps(val self: Long) extends AnyVal { + def toU64HexString: String = "%016x".format(self) + } + +} diff --git a/util-core/src/main/scala/com/twitter/conversions/percent.scala b/util-core/src/main/scala/com/twitter/conversions/percent.scala index c04ee79cb3..90a5fa942d 100644 --- a/util-core/src/main/scala/com/twitter/conversions/percent.scala +++ b/util-core/src/main/scala/com/twitter/conversions/percent.scala @@ -17,6 +17,7 @@ import scala.language.implicitConversions * -10.percent == -0.1 * }}} */ +@deprecated("Use the AnyVal version `com.twitter.conversions.PercentOps`", "2018-12-05") object percent { private val BigDecimal100 = BigDecimal(100.0) diff --git a/util-core/src/main/scala/com/twitter/conversions/storage.scala b/util-core/src/main/scala/com/twitter/conversions/storage.scala index a7dfef97e6..a34d5155b7 100644 --- a/util-core/src/main/scala/com/twitter/conversions/storage.scala +++ b/util-core/src/main/scala/com/twitter/conversions/storage.scala @@ -19,6 +19,7 @@ package com.twitter.conversions import com.twitter.util.StorageUnit import scala.language.implicitConversions +@deprecated("Use the AnyVal version `com.twitter.conversions.StorageUnitOps`", "2018-12-05") object storage { class RichWholeNumber(wrapped: Long) { def byte: StorageUnit = bytes diff --git a/util-core/src/main/scala/com/twitter/conversions/string.scala b/util-core/src/main/scala/com/twitter/conversions/string.scala index aa6aae5000..2e15ee3fb3 100644 --- a/util-core/src/main/scala/com/twitter/conversions/string.scala +++ b/util-core/src/main/scala/com/twitter/conversions/string.scala @@ -19,6 +19,7 @@ package com.twitter.conversions import scala.language.implicitConversions import scala.util.matching.Regex +@deprecated("Use the AnyVal version `com.twitter.conversions.StringOps`", "2018-12-05") object string { final class RichString(wrapped: String) { diff --git a/util-core/src/main/scala/com/twitter/conversions/thread.scala b/util-core/src/main/scala/com/twitter/conversions/thread.scala index ff4afab986..b03209c153 100644 --- a/util-core/src/main/scala/com/twitter/conversions/thread.scala +++ b/util-core/src/main/scala/com/twitter/conversions/thread.scala @@ -22,6 +22,7 @@ import scala.language.implicitConversions /** * Implicits for turning a block of code into a Runnable or Callable. */ +@deprecated("Use `com.twitter.conversions.ThreadOps`", "2018-12-05") object thread { implicit def makeRunnable(f: => Unit): Runnable = new Runnable() { def run(): Unit = f } diff --git a/util-core/src/main/scala/com/twitter/conversions/time.scala b/util-core/src/main/scala/com/twitter/conversions/time.scala index 993dbf4d6f..d8e4d9edf2 100644 --- a/util-core/src/main/scala/com/twitter/conversions/time.scala +++ b/util-core/src/main/scala/com/twitter/conversions/time.scala @@ -20,6 +20,7 @@ import com.twitter.util.Duration import java.util.concurrent.TimeUnit import scala.language.implicitConversions +@deprecated("Use the AnyVal version `com.twitter.conversions.DurationOps`", "2018-12-05") object time { class RichWholeNumber(wrapped: Long) { def nanoseconds: Duration = Duration(wrapped, TimeUnit.NANOSECONDS) diff --git a/util-core/src/main/scala/com/twitter/conversions/u64.scala b/util-core/src/main/scala/com/twitter/conversions/u64.scala index 2e04428296..6e898db129 100644 --- a/util-core/src/main/scala/com/twitter/conversions/u64.scala +++ b/util-core/src/main/scala/com/twitter/conversions/u64.scala @@ -1,5 +1,6 @@ package com.twitter.conversions +@deprecated("Use `com.twitter.conversions.U64Ops`", "2018-12-05") object u64 { /** diff --git a/util-core/src/main/scala/com/twitter/util/Duration.scala b/util-core/src/main/scala/com/twitter/util/Duration.scala index 188ddca04c..295b821911 100644 --- a/util-core/src/main/scala/com/twitter/util/Duration.scala +++ b/util-core/src/main/scala/com/twitter/util/Duration.scala @@ -5,7 +5,9 @@ import java.util.concurrent.TimeUnit object Duration extends TimeLikeOps[Duration] { - def fromNanoseconds(nanoseconds: Long): Duration = new Duration(nanoseconds) + def fromNanoseconds(nanoseconds: Long): Duration = + if (nanoseconds == 0L) Zero + else new Duration(nanoseconds) // This is needed for Java compatibility. override def fromFractionalSeconds(seconds: Double): Duration = @@ -14,12 +16,12 @@ object Duration extends TimeLikeOps[Duration] { override def fromMilliseconds(millis: Long): Duration = super.fromMilliseconds(millis) override def fromMicroseconds(micros: Long): Duration = super.fromMicroseconds(micros) - val NanosPerMicrosecond = 1000L - val NanosPerMillisecond = NanosPerMicrosecond * 1000L - val NanosPerSecond = NanosPerMillisecond * 1000L - val NanosPerMinute = NanosPerSecond * 60 - val NanosPerHour = NanosPerMinute * 60 - val NanosPerDay = NanosPerHour * 24 + val NanosPerMicrosecond: Long = 1000L + val NanosPerMillisecond: Long = NanosPerMicrosecond * 1000L + val NanosPerSecond: Long = NanosPerMillisecond * 1000L + val NanosPerMinute: Long = NanosPerSecond * 60 + val NanosPerHour: Long = NanosPerMinute * 60 + val NanosPerDay: Long = NanosPerHour * 24 /** * Create a duration from a [[java.util.concurrent.TimeUnit]]. @@ -36,7 +38,7 @@ object Duration extends TimeLikeOps[Duration] { } // This is needed for Java compatibility. - override val Zero: Duration = fromNanoseconds(0) + override val Zero: Duration = new Duration(0) /** * Duration `Top` is greater than any other duration, except for diff --git a/util-core/src/test/scala/com/twitter/conversions/DurationOpsTest.scala b/util-core/src/test/scala/com/twitter/conversions/DurationOpsTest.scala new file mode 100644 index 0000000000..4a196e4431 --- /dev/null +++ b/util-core/src/test/scala/com/twitter/conversions/DurationOpsTest.scala @@ -0,0 +1,20 @@ +package com.twitter.conversions + +import com.twitter.util.Duration +import org.scalatest.FunSuite +import com.twitter.conversions.DurationOps._ + +class DurationOpsTest extends FunSuite { + test("converts Duration.Zero") { + assert(0.seconds eq Duration.Zero) + assert(0.milliseconds eq Duration.Zero) + assert(0.seconds eq 0.seconds) + } + + test("converts nonzero durations") { + assert(1.seconds == Duration.fromSeconds(1)) + assert(123.milliseconds == Duration.fromMilliseconds(123)) + assert(100L.nanoseconds == Duration.fromNanoseconds(100L)) + } + +} diff --git a/util-core/src/test/scala/com/twitter/conversions/PercentOpsTest.scala b/util-core/src/test/scala/com/twitter/conversions/PercentOpsTest.scala new file mode 100644 index 0000000000..4bd407a97d --- /dev/null +++ b/util-core/src/test/scala/com/twitter/conversions/PercentOpsTest.scala @@ -0,0 +1,50 @@ +package com.twitter.conversions + +import com.twitter.conversions.PercentOps._ +import org.scalacheck.Arbitrary.arbitrary +import org.scalacheck.Gen +import org.scalatest.FunSuite +import org.scalatest.prop.GeneratorDrivenPropertyChecks + +class PercentOpsTest extends FunSuite with GeneratorDrivenPropertyChecks { + + private[this] val Precision = 0.0000000001 + + private[this] def doubleEq(d1: Double, d2: Double): Boolean = { + Math.abs(d1 - d2) <= Precision + } + + test("percent can be fractional and precision is preserved") { + assert(99.9.percent == 0.999) + assert(99.99.percent == 0.9999) + assert(99.999.percent == 0.99999) + assert(12.3456.percent == 0.123456) + } + + test("percent can be > 100") { + assert(101.percent == 1.01) + assert(500.percent == 5.0) + } + + test("percent can be < 0") { + assert(-0.1.percent == -0.001) + assert(-500.percent == -5.0) + } + + test("assorted percentages") { + forAll(arbitrary[Int]) { i => + assert(new RichDouble(i).percent == i / 100.0) + } + + // We're not as accurate when we get into high double-digit exponents, but that's acceptable. + forAll(Gen.choose(-10000D, 10000D)) { d => + assert(doubleEq(new RichDouble(d).percent, d / 100.0)) + } + } + + test("doesn't blow up on edge cases") { + assert(Double.NaN.percent.equals(Double.NaN)) + assert(Double.NegativeInfinity.percent.equals(Double.NegativeInfinity)) + assert(Double.PositiveInfinity.percent.equals(Double.PositiveInfinity)) + } +} diff --git a/util-core/src/test/scala/com/twitter/conversions/StorageUnitOpsTest.scala b/util-core/src/test/scala/com/twitter/conversions/StorageUnitOpsTest.scala new file mode 100644 index 0000000000..2e46ba726b --- /dev/null +++ b/util-core/src/test/scala/com/twitter/conversions/StorageUnitOpsTest.scala @@ -0,0 +1,29 @@ +package com.twitter.conversions + +import com.twitter.util.StorageUnit +import org.scalatest.FunSuite +import com.twitter.conversions.StorageUnitOps._ + +class StorageUnitOpsTest extends FunSuite { + + test("converts") { + assert(StorageUnit.fromBytes(1) == 1.byte) + assert(StorageUnit.fromBytes(2) == 2.bytes) + + assert(StorageUnit.fromKilobytes(1) == 1.kilobyte) + assert(StorageUnit.fromKilobytes(3) == 3.kilobytes) + + assert(StorageUnit.fromMegabytes(1) == 1.megabyte) + assert(StorageUnit.fromMegabytes(4) == 4.megabytes) + + assert(StorageUnit.fromGigabytes(1) == 1.gigabyte) + assert(StorageUnit.fromGigabytes(5) == 5.gigabytes) + + assert(StorageUnit.fromTerabytes(1) == 1.terabyte) + assert(StorageUnit.fromTerabytes(6) == 6.terabytes) + + assert(StorageUnit.fromPetabytes(1) == 1.petabyte) + assert(StorageUnit.fromPetabytes(7) == 7.petabytes) + } + +} diff --git a/util-core/src/test/scala/com/twitter/conversions/U64Test.scala b/util-core/src/test/scala/com/twitter/conversions/U64OpsTest.scala similarity index 77% rename from util-core/src/test/scala/com/twitter/conversions/U64Test.scala rename to util-core/src/test/scala/com/twitter/conversions/U64OpsTest.scala index aa98441cf4..32736a0b38 100644 --- a/util-core/src/test/scala/com/twitter/conversions/U64Test.scala +++ b/util-core/src/test/scala/com/twitter/conversions/U64OpsTest.scala @@ -3,9 +3,8 @@ package com.twitter.conversions import org.scalatest.FunSuite import org.scalatest.prop.GeneratorDrivenPropertyChecks -class U64Test extends FunSuite with GeneratorDrivenPropertyChecks { - - import u64._ +class U64OpsTest extends FunSuite with GeneratorDrivenPropertyChecks { + import com.twitter.conversions.U64Ops._ test("toU64HextString") { forAll { l: Long =>