Should we add a PeriodInterface or not ?
nyamsprod opened this issue · comments
I'm currently developping the new major release of League\Period.
What's new in this new release apart from deprecations and removal of old PHP versions support is the addition of a League\Period\Collection
class and a League\Period\PeriodInterface
interface.
The main issue is do we really need a PeriodInterface
?
Pros
- This interface is uses by the
Period
class and theCollection
which let's people implements their ownPeriod
class if they want knowing the theCollection
class will always works for them. - This makes making the
Period
class a final class easier as you can rely on a interface
Cons
- The interface does not contains all the methods of the
Period
class because:- most of the
Period
modifier methods can be rebuilt usingPeriodInterface::startingOn
andPeriodInterface::endingOn
. for instancePeriodInterface::withDurationBeforeEnd
is built usingPeriodInterface::startingOn
so you don't need it in the interface; - all the comparisons methods uses
PeriodInterface::compareDuration
so their is no need IMHO to add aPeriodInterface::greaterThan
method in the interface for instance.
- most of the
Period
most valuable methods are its named constructors which by definition won't be in an interface- While
Period
implements the interface, the class also uses the parameter widening concept to allow more flexibility on users input without having to add more methods. In other words, the interface only exposes a fraction of whatPeriod
can do. This means that theCollection
object is restricted to a set of method for better interoperability.
TL;DR: do we really need the PeriodInterface
at all ?
- The PeriodInterface
- The Collection
Thoughts @shadowhand @frankdejonge @bpolaszek ?
I think having final class Period
along with PeriodInterface
is a good approach. I would suggest adding one more class:
abstract class PeriodProxy implements PeriodInterface
{
/** var PeriodInterface */
protected $period;
public function __construct(PeriodInterface $period)
{
$this->period = $period;
}
// implement every method in period with as a proxy call to $this->period
}
This makes it far easier for any developer that wants to customize Period with additional methods to do so. It becomes as simple as:
class MyPeriod extends PeriodProxy
{
public function getStartDate(): DateTimeImmutable
{
// or any other method i want to customize
// all other methods work as normal
}
}
Isn't a period just a value object? What any extension could simply, instead of inheriting from a base class, use the Period class instead. I'd advice against this because it's a whole different level of BC you'll now have to support. Even protected method changes will be BC breaks. It's not an enjoyable thing to have to do. I strongly recommend against it.
@frankdejonge yes Period is a value object.
Before (ie in v3) it was only protected, now it is made final. Making it final prevents any extension by inheritance.
Currently in the master I've added:
1 - the interface League\Period\Interval
2 - An implementing final class League\Period\Period
3 - An abstract proxy class for those who wants to add more functionalities League\Period\ProxyInterval
build using an internal League\Period\Interval
implementing object.
I'll be honest I'm not totally sold on the Interface addition hence this issue being brought on on regular basis. If we choose not to include the Interface, then the proxy class will have no meaning to still be included in the library.
Ah, ok, I read over that, sorry! This makes it a lot less harmful for maintaining it and it gives a better light on how @shadowhand imagined it. Personally I'd still recommend against using such a proxy and I always promote composition instead it because it's clearer, interfaces are smaller thus more effective. But for quick wins I guess it's ok. 🤷♂️
@frankdejonge indeed I too prefer composition that's the main reason why this issue exists.
- Should we promote composition by removing the Interface ?
- Does having an Interface rules out automatically the fact that composition should be preferred over inheritance ?
We should/must be clear about this on the get/go so that issues related to this discussion can be clearly responded to
@shadowhand @frankdejonge I've been undecided about what to do as the new version is feature complete and until this is resolve I won't release the new version. I think I'm going to go opinionated and remove the Interface. Like @frankdejonge pointed Period is a immutable value object so the best way to use Period is via composition so let's promote this way of coding.
Looking at some recently released code in another popular package made me realize that introducing an interface even for the sake of making the code more "interoperable" as the negative side effect of restricting any VO usage even making some dangerous assumption about the code you are using. As such let's favor composition over inheritance and remove the Interface.
@nyamsprod My opinion is the following:
Period
should be a simple, immutable value object - its only responsibility is to handle astartDate
and anendDate
and some helper-getters to read info about them (getDateInterval()
,getTimestampInterval()
,compareDuration()
, ...). Some helper-cloners methods may be added in order to clone the object with different start / end dates, i. e.withDurationAfterStart()
, ... We're then in a true value object, which just handles values and reads them. It indeed doesn't need any interface at all and can be marked final.- What a developer may want to change is the process of instanciating
Period
s. My use case was that I needed periods fromA 00:00:00
toB 23:59:59
(instead of defaultB 00:00:00
), and keeping the same behavior and helpers for the whole library. So I suggest creating aPeriodFactory
along with aPeriodFactoryInterface
, responsible of creating, and even cloning, Period objects. As a consequence, we remove all static methods fromPeriod
.
So what comes to my mind is a design like this:
Period, a final, simple value object with accessors
namespace League\Period;
use DateInterval;
use DateTimeImmutable;
use InvalidArgumentException;
final class Period
{
/**
* @var DateTimeImmutable
*/
private $startDate;
/**
* @var DateTimeImmutable
*/
private $endDate;
/**
* @var PeriodFactoryInterface
*/
private $factory;
public function __construct(DateTimeImmutable $startDate, DateTimeImmutable $endDate, PeriodFactoryInterface $factory = null)
{
if ($startDate > $endDate) {
throw new InvalidArgumentException('The ending datepoint must be greater or equal to the starting datepoint');
}
$this->startDate = $startDate;
$this->endDate = $endDate;
$this->factory = $factory ?? new PeriodFactory();
}
public function getStartDate(): DateTimeImmutable
{
return $this->startDate;
}
public function getEndDate(): DateTimeImmutable
{
return $this->endDate;
}
public function getDateInterval(): DateInterval
{
return $this->startDate->diff($this->endDate);
}
// ...
public function endingOn($datepoint): self
{
return $this->factory->endingOn($this, $datepoint);
}
}
A contract for instanciating Period objects
namespace League\Period;
use DatePeriod;
interface PeriodFactoryInterface
{
/**
* @param mixed $startDate
* @param mixed $endDate
* @return Period
*/
public function createFromDatepoints($startDate, $endDate): Period;
/**
* The implementation MUST throw an \InvalidArgumentException if $datePeriod contains no end date.
*
* @param DatePeriod $datePeriod
* @return Period
*/
public function createFromDatePeriod(DatePeriod $datePeriod): Period;
/**
* @param Period $period
* @param $datepoint
* @return Period
*/
public function endingOn(Period $period, $datepoint): Period;
// ...
}
A built-in Period factory
namespace League\Period;
use DatePeriod;
use DateTimeInterface;
use InvalidArgumentException;
final class PeriodFactory implements PeriodFactoryInterface
{
/**
* @inheritDoc
*/
public function createFromDatepoints($startDate, $endDate): Period
{
return new Period(datepoint($startDate), datepoint($endDate), $this);
}
/**
* @inheritDoc
*/
public function createFromDatePeriod(DatePeriod $datePeriod): Period
{
if (!$datePeriod->getEndDate() instanceof DateTimeInterface) {
throw new InvalidArgumentException('The submitted DatePeriod object does not contain an end datepoint');
}
return new Period(datepoint($datePeriod->getStartDate()), datepoint($datePeriod->getEndDate()), $this);
}
/**
* @inheritDoc
*/
public function endingOn(Period $period, $datepoint): Period
{
return new Period($period->getStartDate(), datepoint($datepoint), $this);
}
// ...
}
The developer can create his own
namespace MyApp\Period;
use DatePeriod;
use DateTimeImmutable;
use DateTimeInterface;
use InvalidArgumentException;
use function League\Period\datepoint;
use League\Period\Period;
use League\Period\PeriodFactoryInterface;
final class BpolaszekPeriodFactory implements PeriodFactoryInterface
{
/**
* @inheritDoc
*/
public function createFromDatepoints($startDate, $endDate): Period
{
return new Period(datepoint($startDate), $this->ensure235959($endDate), $this);
}
/**
* @inheritDoc
*/
public function createFromDatePeriod(DatePeriod $datePeriod): Period
{
if (!$datePeriod->getEndDate() instanceof DateTimeInterface) {
throw new InvalidArgumentException('The submitted DatePeriod object does not contain an end datepoint');
}
return new Period(datepoint($datePeriod->getStartDate()), $this->ensure235959($datePeriod->getEndDate()), $this);
}
/**
* @inheritDoc
*/
public function endingOn(Period $period, $datepoint): Period
{
return new Period($period->getStartDate(), $this->ensure235959($datepoint), $this);
}
private function ensure235959($datepoint): DateTimeImmutable
{
// if this fails, it's none of @nyamsprod's business
return datepoint($datepoint)->modify('midnight -1 second'); // todo improve
}
// ...
}
What do you think?
@bpolaszek I like the idea of removing the factory part of Period but I don't like the idea of creating a Factory and a Factory interface. I mean depending on business requirement what you call a month or week may drastically change so making it part of any interface is an open can of worms don't you think ?
I would however maybe consider creating small functions in the Period namespace like
use League\Period;
$period = Period\month(2018, 9);
$altPeriod = Period\month_from_datepoint('2018-09-15');
$period->equalsTo($altPeriod); // should return true
And remove any createFromXXX
named constructors from the main class. Leaving anyone who has a different definition to create his own Period Factory.
But that's a different topic maybe we should create a separate issue for that
@bpolaszek I like the idea of removing the factory part of Period but I don't like the idea of creating a Factory and a Factory interface. I mean depending on business requirement what you call a month or week may drastically change so making it part of any interface is an open can of worms don't you think ?
I honestly don't think so.
With the PeriodFactoryInterface
the only contract is to instanciate Period
objects. Whether or not a business requirement changes the internal values of a Period
object is none of your business, the job is done anyway.
Another use case I can see is a createWeek() function. Most of the time (and according to international standard ISO 8601) a week begins on monday. So $periodFactory->createWeek('2018-09-06')
should return a Period starting at 2018-09-03
to 2018-06-09
. But wait, I'm an American developer. Then $myOwnPeriodFactory->createWeek('2018-09-06')
will return a Period starting at 2018-09-02
to 2018-06-08
. Both Period
and PeriodFactory
are still valid, aren't they?
Another use case I can see is a createWeek() function. Most of the time (and according to international standard ISO 8601) a week begins on monday. So
$periodFactory->createWeek('2018-09-06')
should return a Period starting at2018-09-03 to
2018-06-09. But wait, I'm an American developer. Then
$myOwnPeriodFactory->createWeek('2018-09-06')will return a Period starting at
2018-09-02to
2018-06-08. Both
Periodand
PeriodFactory `are still valid, aren't they?
I expect any implementation of any interface to always returns the same result (maybe with a different implementation but the same input should returns the same output minus 🐛 ) otherwise there is simply no point in having an interface. So even thought the returned Period
is valid the PeriodFactoryInterface
contract is not respected.
Let's take a simpler example ... DateTime
and DateTimeImmutable
shares the same methods and the same methods signature BUT their common interface DateTimeInterface
only exposes their getter methods Why ? because otherwise the contract would be broken. because even thought both classes share the same modifiers methods signature their return type are completely different. By only exposing the getter methods the interface make sense if you add the modifier methods then all is broken because you've broken code expectation.
As a sidenote that is why I renamed createFromWeek
to createFromISOWeek
to make it more clear. And to remove any ambiguity.
DateTime and DateTimeImmutable shares the same methods and the same methods signature BUT their common interface DateTimeInterface only exposes their getter methods.
Hm, that's not completely true.
DateTimeInterface::diff()
, for instance, is a contract for a DateInterval
factory.
I can create my own class CompletelyStupidDateTime implements DateTimeInterface
implementation and, when CompletelyStupidDateTime::diff()
is called, I can return a DateInterval
object with a random value in it, because the contract absolutely doesn't care about implementation requirements. As long as I return a DateInterval
object, the contract cannot be considered broken.
On the other hand, DateTimeInterface::modify()
does not exist because it can either return a DateTime
or a DateTimeImmutable
which do not technically behave the same way: this would indeed be a Liskov violation.
The PeriodFactoryInterface
I suggest would only provide final, well defined, Period
objects (no PeriodInterface
). So we're not talking about getters or modifiers here, we're talking about object instanciation. And having interfaces for factories is something quite common, isn't it?
Besides, since PHP 7 the return type can be enforced. So, no matter how you implement the interface, the implementation MUST return a Period object (even with dummy values) or the contract will be broken. A TypeError
will be raised in such case.
As a sidenote that is why I renamed createFromWeek to createFromISOWeek to make it more clear. And to remove any ambiguity.
If you rename a method createFromWeek
to createFromISOWeek()
you're assuming people using your library will necessarily create weeks starting on Mondays. To my eyes, this is an implementation enforcement and has nothing to deal with the concept of Period. Because the concept of week can be different from one point of the world to another, the concept of a period isn't.
I can create my own class
CompletelyStupidDateTime
implementsDateTimeInterface
implementation and, whenCompletelyStupidDateTime::diff()
is called, I can return aDateInterval
object with a random value in it, because the contract absolutely doesn't care about implementation requirements. As long as I return aDateInterval
object, the contract cannot be considered broken.
That's where we differ in interpretation, the Interface includes the return type and the docblock comments/verbal or written informationx which is associated with it. For instance DateTimeInterface::diff
does not simply return a random DateInterval
object. It returns
Returns the difference between two DateTime objects
Those objects are the subject DateTime
and the argument DateTime
object you compare to it as defined in the PHP documentation website.
If you choose to return any kind of DateInterval
object you are breaking the contract. Maybe a PHP static analyzer or lack of test suite won't catch this error but it is a clear violation of the expected contract.
If you rename a method createFromWeek to createFromISOWeek() you're assuming people using your library will necessarily create weeks starting on Mondays.
No I expect people who uses this named constructor to know the difference between an ISO week and a "standard" week aka a period of 7 days which can start whenever you want. If they required another week definition they can simply use the default constructor or the createFromDurationAfterStart
named constructors. Or just build the factory they want. Bottom like the best factory right now on the Period
class is its constructor.
And If I remove all the named constructor and replace them with tiny function is becomes even clearer
That's where we differ in interpretation, the Interface includes the return type and the docblock comments/verbal or written information which is associated with it. For instance DateTimeInterface::diff does not simply return a random DateInterval object. It returns "the difference between two DateTime objects"
We indeed differ in interpretation. To my eyes, an interface must have no knowledge of how right or wrong the implementation should work. Consider this example:
interface PersonInterface
{
/**
* The implementation MUST return "hello".
*
* @return string
*/
public function sayHello(): string;
}
final class Asshole implements PersonInterface
{
/**
* @inheritDoc
*/
public function sayHello(): string
{
return 'fuck you';
}
}
This is completely valid because in this case, checking the validity of the returned content is the domain responsibility, not the contract one.
Returning a DateInterval
with a random value will still return a difference between 2 DateTime objects, which can be right, wrong, or mocked. Of course these examples are extreme and insane, but if an interface says too much about what the implementation must do (apart from when it should throw an exception, for instance), then this interface is completely useless, in my humble opinion.
I believe you are mistaking return types and interfaces. If I'm only looking at return types your are right but Interfaces are more than just stubs with clearly defined return types. The metadata released with the Interface is as important. For instance if I were to follow your thought I could never rely on PSR-7 and it's withXXX
methods. as the return type returns states to return the same object BUT the metadata (comments/phpdoc) states returns the same object with property X changed. If I were to follow your principle then it means that avec using one of those method I should double check that what I expected really happened 😢 . This kills the principle of least astonishment.
To stay on topic. Named constructors are just simple functions to generate specific Period instance If they do not fit your business requirement that's OK there's still the main __construct
class that can be use to create virtually any type of Period
object so I fail to see the added value of having a PeriodFactoryInterface
which If I follow your rationality would just be something like this
interface PeriodModifierInterface
{
public function process(Period $period): Period;
}
final class PeriodMinus1Second implements PeriodModifierInterface
{
public function process(Period $period): Period
{
//naive implementation
return $period->endingOn($period->getEndDate()->sub(new DateInterva('PT1S')));
}
}
$period = (new PeriodMinus1Second())->process(Period::createFromMonth(2018, 9));
This is also the reason why I think have them as companion functions define in the League\Period
namespace as opposed to having them as named constructor as you suggested would IMO improve their usage/understanding by disconnecting them from the main VO
@nyamsprod after reviewing all the responses here, I think that @frankdejonge is right. There is little to no value in having a proxy object. Just leave Period as a final
VO and call it done. Composition over inheritance.
@shadowhand that's what I've done already. I'll consider this issue resolve. Thanks everyone for participating 👍