Стабилен софтуер на теория

Jan 21, 2021 02:40 · 1191 words · 6 minute read

Стабилен софтуер е не когато помислим за всичко, което може да се случи на практика - а когато помислим за всичко, което може да се случи и на теория

“На теория практиката е същото като теорията, но на практика не е.” - народна мъдрост

Попаднах на интересна статия за добавянето на поддръжка на Firefox за Apple Silicon (ARM64) и macOS Big Sur (11.0). Интересна ми стана частта, в която описват проблеми, породени от версията на macOS. Досега години наред, откакто Apple преминаха на Intel, macOS версиите бяха 10.x . Това е подтикнало в главите на доста програмисти да се разиграе следната ситуация:

  • Унуфри: “Трябва да разбера на коя версия на macOS върви програмата.”
  • Също Унуфри: “Мога да парсна числата като стандартна semver версия: x.y.z, да ги сравня последователно “лексикографски” и да проверя дали е по-голямо от 10.14.”
  • Отново Унуфри: “Обаче вече 20 години версиите не са се променяли и стоят на 10. Едва ли ще увеличат major версията. Значи мога да извадя minor числото и да сравня само него: parseInt(/\d\.(\d)/.match(osVersion)[0], 10) > 13. Готово, много по-лесно и работи.”

Каква грешка допусна Унуфри? Направи предположение, което на практика е вярно, но на теория не е. На практика между това допускане и реалното увеличение на версията са минали поне 3 години. Изведнъж практиката настига теорията и проверката започва да сравнява с нулата от 11.0.

Голям проблем ли е това? В случая не - работило е много време, оправили са го лесно. Но подобни неща се натрупват - ако има много пропуски, само заради броя им вероятно често ще се появяват нови и нови проблеми.

Поради същата причина Microsoft пропуснаха версия 9. Заради много други инстанции на Унуфри, казващи си, че това е “достатъчно добро на практика”: const isWin9x = osVersion[0] === '9';.

Поради подобни причини имаме и Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36 OPR/73.0.3856.344 за User Agent идентификатор във всеки модерен браузър. Това е и причината macOS Safari на ARM64 да докладва Intel Mac OS X 10_15_6 вместо Apple Silicon Mac OS X 11_0_0, въпреки, че няма нищо от Intel в хардуера.

Примерите помагат за разбиране, но не са документация

Следвайки примерите, Унуфри стига до извода, че 10.x е форматът, с който трябва да работи. Разбирайки, че това е версия, следователно може да има стойности, различни от примерите, човек може да стигне до извода, че трябва да го прочете като версия и да се съобрази с всички компоненти.

Както в условните задачи по математика - ако нещо е дадено, значи вероятно трябва да се ползва. Или поне да се игнорира съзнателно и с достатъчно обяснение.

Не е достатъчно да видим какви стойности връща дадено API - трябва да прочетем документацията. Изключително често ми се случва да си спестявам часове дебъгване още преди да съм написал кода - като просто отделя 15 минути да прочета внимателно документацията на библиотеката, която ще използвам. Ако maintainer-ите на библиотеката са отделили цели дни в описанието, значи има смисъл от това, което го пише там.

Например, изчитайки описането на една библиотека, която дори не използвах nextjs-serverless-lambda-handler, ми отвори очите за детайл, който не съм и подозирал, че съществува. Този детайл все още нямаше да съм го разбрал, месец по-късно и щеше да се появи в най-неподходящия момент, изключително неприятен за дебъгване.

На типовата система трябва да се угажда

На всеки, който е писал на TypeScript поне веднъж му се е случвало да използва non-null-assertion оператора (somethingThatCanBeNull!.property).

В момента, в който пишем това винаги имаме ясна причина в главата си защо в този случай е вярно. На практика. Но типовата система казва, че на теория е възможно и да не е така - и рано или късно ще се окаже права. Предположението, което правим е вярно сега, но не можем да гарантираме, че ще бъде вярно след месец и 200 commit-а по-късно.

Един пример от миналата година. В един React компонент - главен компонент за екран, имаше следното:

function OrderConfirmationScreen() {
  // We're only getting to this screen when we check out an order,
  // so we are guaranteed to have the order in Redux already.
  const order = useSelector(getCheckoutOrder)!;

  // ... code that uses order without checking if present ...
}

Изглежда вярно, докато не се появят три допълнителни промени, всяка по себе си запазваща правилото, но трите заедно го чупят:

  1. В някои ситуации се появява нов екран между този и предходния.
  2. Ъпдейтва се библиотеката за навигация - новото подразбиращо се поведение е този екран да остане mount-нат по-дълго, отколкото е бил преди, дори да не се вижда.
  3. Започваме да зачистваме order-а от Redux, за да оправим друг бъг.

Изведнъж теорията настига практиката и в някои ситуации мобилното приложение започва да крашва, показвайки бял екран. Причината е JavaScript еквивалента на null pointer exception в React компонент, който дори не се вижда в този момент. Ако типовата система можеше да говори, би казала надменно “Казах ти, че това ще е проблем, ама ти - не”.

Ако бяхме угодили на типовата система, поведението щеше да се съобразява с тази възможности - например чрез съобщение или предаване на стойността по не-глобален начин, който може да се верифицира автоматично.

Понякога дори if (!something) { throw "This should not happen because ..." } е по-добро решение от non-null assertion, който после ще каже Cannot read property X of undefined.

Грешките са част от теорията

Всеки ред код може да предизвика (различни видове) грешки.

Това е очевидно на теория, но на практика го забравяме често. Да не се интересуваме от тези ситуации е програмисткият еквивалент на заравяне на глава в пясъка. Разбира се, трябва да обработваме грешките само на места, където има какво да направим по въпроса. А когато не можем да ги обработим - поне трябва да покажем някак на бъдещите дебъгващи.

Често се случва да видим нещо такова:

function loadSomeOptionalData() {
    try {
        ...
    } catch {
        return undefined;
    }
}

На практика всичко е точно - грешката, че не могат да се заредят данните се игнорира и се връща празна стойност. На теория, обаче, грешката не винаги е тази, която очакваме. Всякакви грешки, дори Cannot read property X of undefined, ще бъдат хванати, игнорирани и няма да има опционалните данни. Тъй като данните са опционални, такъв проблем може да остане незабелязан много дълго време.

За да се съобразим с теорията трябва да направим това:

function loadSomeOptionalData() {
    try {
        ...
    } catch (error) {
        if (error instanceOf CouldNotLoadDataError) {
            logger.warn("Could not load optional data X", error);
            return undefined;
        }

        throw error;
    }
}

Така елиминираме предположението “грешката е тази, която очакваме”. Ще разберем за проблема в момента, в който този код се счупи по неправилен начин.

Постоянно правим assumption-и, трябва да сме болезнено наясно с тях

Най-често бъговете са резултат от грешни предположения за това как работи (или как трябва да работи) нещо.

Колкото по-дисциплинирани сме в това да изброим разбиранията си за нещо и да разграничим предположенията от фактите - толкова по-лесно излизаме от ситуацията, в която си скубем косите гледайки парче код с часове. Извиквайки всеки метод да се замисляме сигурни ли сме в поведението му. И ако не сме - да проверим документацията.

Debugging is like being the detective in a crime movie where you are also the murderer.