RSS

Programowanie bazujące na kontraktach a JavaScript

Liczba odsłon: 163

Jednym ze spo­so­bów za­pew­nie­nia wy­so­kiej ja­koś­ci opro­gra­mo­wa­nia jest sto­so­wa­nie tech­ni­ki kon­trak­tów. Zakłada się w niej, że każ­dy pod­pro­gram opi­sa­ny jest kon­trak­tem wej­ścio­wym (ang. pre­con­di­tions) oraz wyj­ścio­wym (ang. post­con­di­tions). Kontrakt wej­ścio­wy gwa­ran­tu­je, że wszyst­kie da­ne po­trzeb­ne do reali­zac­ji ope­rac­ji są po­praw­ne (na­le­żą do dzie­dzi­ny funk­cji), a wyj­ścio­wy — że wy­nik nie wy­cho­dzi po­za za­kres do­pusz­czal­nych war­toś­ci (na­le­ży do prze­ciw­dzie­dzi­ny).

Najprost­szym przy­kła­dem sto­so­wa­nia tech­ni­ki kon­trak­tów jest pod­pro­gram obli­cza­ją­cy ob­wód okrę­gu. Zapisa­ny w ję­zy­ku Java­Script, wy­glą­da na­stę­pu­ją­co:

fun­ction cir­cum­fe­ren­ce(r) {
   return 2 * Math.PI * r;
}

Taki za­pis jest oczy­wis­ty, przej­rzy­sty i krót­ki, jed­nak nie gwa­ran­tu­je po­praw­noś­ci dzia­ła­nia. Po pierw­sze, po­da­nie błęd­ne­go r spo­wo­du­je uzys­ka­nie wy­ni­ku po­zba­wio­ne­go sen­su fi­zycz­ne­go:

cir­cum­fe­ren­ce(-2)
-12.566370614359172

Po dru­gie, błąd w treś­ci funk­cji – na przy­kład wsta­wie­nie zbęd­ne­go mi­nu­sa – mo­że po­wo­do­wać uzys­ka­nie błęd­ne­go wy­ni­ku mi­mo po­praw­nych da­nych:

cir­cum­fe­ren­ce(2)
-12.566370614359172

Oczywiś­cie, te­go ty­pu błę­dy po­peł­nio­ne w tak pros­tych pod­pro­gra­mach jest łat­wo wy­ła­pać. Powinny też zo­stać zna­le­zio­ne przez pa­kiet te­stów jed­nost­ko­wych, spraw­dza­ją­cych wy­nik dla kil­ku po­praw­nych war­toś­ci. Gorzej jed­nak, gdy pod­pro­gram jest bar­dzo roz­bu­do­wa­ny, a błę­dy po­ja­wia­ją się tyl­ko dla kon­kret­ne­go, bar­dzo ogra­ni­czo­ne­go ze­sta­wu da­nych wej­ścio­wych. W ta­kim przy­pad­ku zwyk­łe te­sty jed­nost­ko­we nie­wie­le po­mo­gą, a pro­gram w nie­któ­rych oko­licz­noś­ciach bę­dzie da­wał złe wy­ni­ki i – co gor­sza – mo­że to ro­bić nie­po­strze­że­nie.

Stąd po­mysł, by za­pew­nić nie­za­wod­ność pro­gra­mu przez sto­so­wa­nie kon­trak­tów. Najbardziej po­moc­ne są kon­trak­ty wej­ścio­we, okre­śla­ją­ce dzie­dzi­nę pod­pro­gra­mu. Najprost­szym spo­so­bem na­rzu­ce­nia kon­trak­tu jest wsta­wie­nie wy­ra­że­nia wa­run­ko­we­go spraw­dza­ją­ce­go ar­gu­men­ty i w ra­zie wy­kry­cia błę­du zgła­sza­ją­ce­go sytu­ację wy­jąt­ko­wą:

function circumference(r) {
   if (typeof r !== 'number') throw new TypeError(
      "The circle's radius should be a number, instead got: " + r);
   if (r < 0) throw new RangeError(
      "The circle's radius should be at least zero.");
   return 2 * Math.PI * r;
}
circumference(-2)
RangeError: The circle's radius should be at least zero.
circumference({})
TypeError: The circle's radius should be a number, instead got: [object Object]
circumference(2)
12.566370614359172

W ten spo­sób gwa­ran­tu­je­my, że każ­da pró­ba wy­wo­ła­nia pod­pro­gra­mu z błęd­nym ar­gu­men­tem przer­wie dzia­ła­nie pro­gra­mu. Zapobieg­nie to kon­ty­nuo­wa­niu błęd­nych obli­czeń i po­zwo­li łat­wo zlo­ka­li­zo­wać źród­ło błę­du.

W po­dob­ny spo­sób im­ple­men­tu­je się kon­trak­ty wyj­ścio­we, któ­rych za­da­niem jest spraw­dza­nie, czy wy­nik funk­cji na­le­ży do zbio­ru po­praw­nych re­zul­ta­tów obli­czeń. Na przy­kład, przed­sta­wio­ny po­wy­żej pod­pro­gram obli­cza­ją­cy ob­wód okrę­gu moż­na za­pi­sać z wy­ko­rzys­ta­niem kon­trak­tu wyj­ścio­we­go:

function circumference(r) {
    var result = 2 * Math.PI * r;
    if (result < 0) throw new RangeError(
       'Circumference cannot be less than zero, but got: ' + result);
    return result;
}

W nie­któ­rych przy­pad­kach kon­trakt wyj­ścio­wy peł­ni ro­lę po­dob­ną do wej­ścio­we­go i po­zwa­la wy­kryć błęd­ne ar­gu­men­ty, choć bez wy­raź­ne­go wska­za­nia ro­dza­ju błę­du:

circumference(2)
12.566370614359172
circumference(-2)
RangeError: Circumference cannot be less than zero, but got: -12.566370614359172

Jest to jed­nak złud­ne. Już w tak pros­tym przy­pad­ku po­da­nie ar­gu­men­tu nie­właś­ci­we­go ty­pu skoń­czy się uzys­ka­niem błęd­ne­go wy­ni­ku, bez jas­ne­go ko­mu­ni­ka­tu błę­du:

circumference({})
NaN
circumference("abc")
NaN

Co gor­sza, w wie­lu przy­pad­kach obli­cze­nia po­tra­fią ukryć błęd­ne da­ne. Gdyby nasz pod­pro­gram ob­li­czał po­le ko­ła za­miast ob­wo­du okrę­gu, po­da­wa­nie ujem­ne­go pro­mie­nia ucho­dzi­ło­by na su­cho. Z te­go po­wo­du nie wol­no re­zyg­no­wać z kon­trak­tów wej­ścio­wych i za­stę­po­wać ich wyj­ścio­wy­mi.

Używanie kon­trak­tów wej­ścio­wych jest prak­ty­ką tak do­brą, że aż war­tą po­le­ce­nia ja­ko obo­wiąz­ko­wa w każ­dym pro­jek­cie. Wątpli­wo­ści moż­na mieć w sto­sun­ku do kon­trak­tów wyj­ścio­wych. Jako re­gu­łę przy­jął­bym, że war­to je sto­so­wać w pod­pro­gra­mach reali­zu­ją­cych dłu­go­trwa­łe, skom­pli­ko­wa­ne obli­cze­nia, któ­rych re­zul­tat po­wi­nien speł­niać pew­ne pros­te ogra­ni­cze­nia (bez­względ­ne lub za­leż­ne od ar­gu­men­tów). W po­zo­sta­łych przy­pad­kach sa­me kon­trak­ty wej­ścio­we w po­łą­cze­niu z te­sta­mi jed­nost­ko­wy­mi za­pew­nią wy­star­cza­ją­cą ja­kość opro­gra­mo­wa­nia.


Zapisy­wa­nie każ­de­go kon­trak­tu w spo­sób opi­sa­ny po­wy­żej jest cza­so­chłon­ne i zna­czą­co zwięk­sza obję­tość tek­stu źród­ło­we­go pro­gra­mu. Warto za­tem za­sto­so­wać roz­wią­za­nie, któ­re uczy­ni za­pi­sy­wa­nie kon­trak­tów bar­dziej de­kla­ra­tyw­nym — tak, aby sku­piać się na wa­run­kach, a nie otocz­ce.

Stworze­nie włas­nej bi­blio­te­ki reali­zu­ją­cej od­po­wied­nią funkcjo­nal­ność nie jest trud­ne. Powyższe przy­kła­dy moż­na by za­pi­sać na­stę­pu­ją­co:

var Contract = {
    '_type': 'Contract',
    'ofType': function(expectedType, value) {
        if (typeof value !== expectedType) throw new TypeError(
           this._type + ' violation: The value "' + value
               + '" was expected to be of type "'
               + expectedType + '", not "' + typeof value + '".');
        return this;
    },
    'nonNegative': function(value) {
        if (value < 0) throw new RangeError(this._type
            + ' violation: The value "' + value
            + '" was expected to be non-negative.');
        return this;
    }
};

var Expect = Object.create(Contract);
Expect._type = 'Precondition';

var Guarantee = Object.create(Contract);
Guarantee._type = 'Postcondition';

function circumference(r) {
    Expect.ofType('number', r)
          .nonNegative(r);
    var result = 2 * Math.PI * r;
    Guarantee.nonNegative(result);
    return result;
}

W du­żym pro­jek­cie in­for­ma­tycz­nym, w któ­rym chce się za­cho­wać jak naj­szer­szą włas­ność i spój­ność ko­du, mo­że to być sen­sow­ne — o ile od­po­wied­nią bi­blio­te­kę bę­dzie się two­rzy­ło i wy­ko­rzys­ty­wa­ło od pierw­sze­go dnia pra­cy. W więk­szoś­ci przy­pad­ków bar­dziej od­po­wied­nie jest jed­nak uży­cie go­to­we­go, prze­tes­to­wa­ne­go roz­wią­za­nia, ta­kie­go jak jsContract, któ­re­go au­to­rem jest Øyvind Sean Kinsey. Należy ono do „lek­kich” w tym sen­sie, że nie wpro­wa­dza do­dat­ko­wych za­leż­noś­ci, nie jest częś­cią więk­szej bi­blio­te­ki i mo­że być wy­ko­rzys­ta­ne bez ko­niecz­noś­ci bu­do­wa­nia ko­du za po­mo­cą spe­cja­li­zo­wa­nych na­rzę­dzi. Aby go użyć, wy­star­czy po­brać plik jsContract.jsre­po­zy­to­rium i do­łą­czyć do swo­jej stro­ny WWW:

<script type="text/javascript" src="jsContract.js"></script>

Przykłado­wa funk­cja obli­cza­ją­ca ob­wód okrę­gu, wy­ko­rzy­stu­ją­ca bi­blio­te­kę jsContract, bę­dzie wy­glą­da­ła na­stę­pu­ją­co:

function circumference(r) {
    Contract.expectNumber(r);
    Contract.expect(r >= 0, "The circle's radius should be at least zero.");
    Contract.guaranteesNumber();
    Contract.guarantees(function(result) {
       return (result > 0);
    }, "Circle's circumference cannot be negative.");
    return 2 * Math.PI * r;
}

Zaskaku­ją­cy pod wzglę­dem po­ło­że­nia w ko­dzie oraz dość roz­wlek­ły spo­sób za­pi­su kon­trak­tu wyj­ścio­we­go wy­ni­ka z prag­ma­tycz­ne­go po­dej­ścia auto­ra. Kontrak­ty wyj­ścio­we są bez­piecz­ni­kiem, któ­ry dzia­ła nie­zmier­nie rzad­ko i sta­no­wi w za­sa­dzie bar­dziej ele­ment ze­sta­wu te­stów jed­nost­ko­wych, niż me­cha­niz­mów za­pew­nia­nia po­praw­noś­ci dzia­ła­nia pro­gra­mu. Z te­go po­wo­du kon­trak­ty wyj­ścio­we są za­pi­sy­wa­ne we­wnątrz funk­cji, któ­rych do­ty­czą, jed­nak nor­mal­nie są po­mi­ja­ne. Dopiero uru­cho­mie­nie od­ręb­nej pro­ce­du­ry in­stru­men­ta­cji stwo­rzy wer­sje pod­pro­gra­mów fak­tycz­nie we­ry­fi­ku­ją­ce speł­nie­nie kon­trak­tu wyj­ścio­we­go.

Podejście ta­kie zwięk­sza wy­daj­ność dzia­ła­nia pro­gra­mu w wer­sji pro­duk­cyj­nej bez re­zyg­no­wa­nia z moż­li­woś­ci zauto­ma­ty­zo­wa­nia tes­to­wa­nia two­rzo­nych pod­pro­gra­mów. Niestety, wią­że się też z wpro­wa­dze­niem do­dat­ko­wych za­leż­noś­ci.


Istnieją rów­nież praw­dzi­wie de­kla­ra­tyw­ne sys­te­my de­fi­nio­wa­nia kon­trak­tów opro­gra­mo­wa­nia pi­sa­ne­go w ję­zy­ku Java­Script. Na przy­kład con­tracts-js in­te­gru­je się z za­rząd­cą pa­kie­tów npm i po­zwa­la de­fi­nio­wać kon­trak­ty w na­głów­ku pod­pro­gra­mu, za po­mo­cą zwar­te­go me­ta-ję­zy­ka. To jed­nak te­mat na osob­ne opra­co­wa­nie.